diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f74f053aab..f13c61be2a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,7 @@ + @@ -313,6 +314,12 @@ + + + + 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 bbf8efa2c4..c1a94d5fd8 100644 --- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt @@ -22,6 +22,7 @@ import android.app.NotificationManager import android.content.Context import android.location.Geocoder import android.media.AudioAttributes +import android.media.AudioManager import android.media.MediaPlayer import androidx.core.app.NotificationManagerCompat import com.wire.android.BuildConfig @@ -48,6 +49,7 @@ annotation class CurrentAppVersion @Module @InstallIn(SingletonComponent::class) +@Suppress("TooManyFunctions") object AppModule { @CurrentAppVersion @@ -104,4 +106,8 @@ object AppModule { @Provides fun provideAnonymousAnalyticsManager(): AnonymousAnalyticsManager = AnonymousAnalyticsManagerImpl + + @Provides + fun provideAudioManager(@ApplicationContext context: Context): AudioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index 39b48db293..cc6e464bea 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -17,6 +17,8 @@ */ package com.wire.android.mapper +import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData @@ -25,7 +27,9 @@ import com.wire.android.ui.home.conversationslist.model.BadgeEventType import com.wire.android.ui.home.conversationslist.model.BlockState import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem +import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation import com.wire.android.ui.home.conversationslist.showLegalHoldIndicator +import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.ConversationDetails.Connection import com.wire.kalium.logic.data.conversation.ConversationDetails.Group import com.wire.kalium.logic.data.conversation.ConversationDetails.OneOne @@ -42,7 +46,8 @@ import com.wire.kalium.logic.data.user.UserAvailabilityStatus fun ConversationDetailsWithEvents.toConversationItem( userTypeMapper: UserTypeMapper, searchQuery: String, - selfUserTeamId: TeamId? + selfUserTeamId: TeamId?, + playingAudioMessage: PlayingAudioMessage ): ConversationItem = when (val conversationDetails = this.conversationDetails) { is Group -> { ConversationItem.GroupConversation( @@ -66,7 +71,8 @@ fun ConversationDetailsWithEvents.toConversationItem( hasNewActivitiesToShow = hasNewActivitiesToShow, searchQuery = searchQuery, isFavorite = conversationDetails.isFavorite, - folder = conversationDetails.folder + folder = conversationDetails.folder, + playingAudio = getPlayingAudioInConversation(playingAudioMessage, conversationDetails) ) } @@ -105,7 +111,8 @@ fun ConversationDetailsWithEvents.toConversationItem( hasNewActivitiesToShow = hasNewActivitiesToShow, searchQuery = searchQuery, isFavorite = conversationDetails.isFavorite, - folder = conversationDetails.folder + folder = conversationDetails.folder, + playingAudio = getPlayingAudioInConversation(playingAudioMessage, conversationDetails) ) } @@ -142,6 +149,25 @@ fun ConversationDetailsWithEvents.toConversationItem( } } +private fun getPlayingAudioInConversation( + playingAudioMessage: PlayingAudioMessage, + conversationDetails: ConversationDetails +): PlayingAudioInConversation? = + if (playingAudioMessage is PlayingAudioMessage.Some + && playingAudioMessage.conversationId == conversationDetails.conversation.id + ) { + if (playingAudioMessage.state.isPlaying()) { + PlayingAudioInConversation(playingAudioMessage.messageId, false) + } else if (playingAudioMessage.state.audioMediaPlayingState is AudioMediaPlayingState.Paused) { + PlayingAudioInConversation(playingAudioMessage.messageId, true) + } else { + // states Fetching, Completed, Stopped, etc. should not be shown in ConversationItem + null + } + } else { + null + } + private fun parseConnectionEventType(connectionState: ConnectionState) = if (connectionState == ConnectionState.SENT) { BadgeEventType.SentConnectRequest diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioFocusHelper.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioFocusHelper.kt new file mode 100644 index 0000000000..a65d15e6e9 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioFocusHelper.kt @@ -0,0 +1,90 @@ +/* + * Wire + * Copyright (C) 2025 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 android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import javax.inject.Inject + +class AudioFocusHelper @Inject constructor(private val audioManager: AudioManager) { + + private var listener: PlayPauseListener? = null + + private val onAudioFocusChangeListener by lazy { + AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + listener?.onPauseCurrentAudio() + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + listener?.onPauseCurrentAudio() + } + + AudioManager.AUDIOFOCUS_GAIN -> { + listener?.onResumeCurrentAudio() + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + listener?.onPauseCurrentAudio() + } + } + } + } + + private val focusRequest by lazy { + AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setOnAudioFocusChangeListener(onAudioFocusChangeListener) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) setForceDucking(true) + }.build() + } + + /** + * Requests the audio focus. + * @return true in case if focus was granted (AudioMessage can be played), false - otherwise + */ + fun request(): Boolean { + return audioManager.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_FAILED + } + + /** + * Abandon the audio focus. + */ + fun abandon() { + audioManager.abandonAudioFocusRequest(focusRequest) + } + + fun setListener(onPauseCurrentAudio: () -> Unit, onResumeCurrentAudio: () -> Unit) { + listener = object : PlayPauseListener { + override fun onPauseCurrentAudio() { + onPauseCurrentAudio() + } + + override fun onResumeCurrentAudio() { + onResumeCurrentAudio() + } + } + } + + interface PlayPauseListener { + fun onPauseCurrentAudio() + fun onResumeCurrentAudio() + } +} 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 63346ae97f..dcf91acb17 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt @@ -19,6 +19,14 @@ package com.wire.android.media.audiomessage import androidx.annotation.StringRes import com.wire.android.R +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.id.ConversationId + +data class AudioMessagesData( + val statesHistory: Map, + val playingMessage: PlayingAudioMessage +) data class AudioState( val audioMediaPlayingState: AudioMediaPlayingState, @@ -40,6 +48,15 @@ data class AudioState( return totalTimeInMs } + fun isPlaying() = audioMediaPlayingState is AudioMediaPlayingState.Playing + fun isPlayingOrPaused() = audioMediaPlayingState is AudioMediaPlayingState.Playing + || audioMediaPlayingState is AudioMediaPlayingState.Paused + + fun isPlayingOrPausedOrFetching() = audioMediaPlayingState is AudioMediaPlayingState.Playing + || audioMediaPlayingState is AudioMediaPlayingState.Paused + || audioMediaPlayingState is AudioMediaPlayingState.Fetching + || audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching + sealed class TotalTimeInMs { object NotKnown : TotalTimeInMs() @@ -47,6 +64,27 @@ data class AudioState( } } +sealed class PlayingAudioMessage { + data object None : PlayingAudioMessage() + data class Some( + val conversationId: ConversationId, + val messageId: String, + val authorName: UIText, + val state: AudioState + ) : PlayingAudioMessage() + + fun isSameAs(that: PlayingAudioMessage): Boolean { + val isTypeSame = (this is Some && that is Some) + || (this is None && that is None) + + val isMessageSame = this is Some && that is Some + && this.messageId == that.messageId + && this.state.isPlaying() == that.state.isPlaying() + + return isTypeSame && isMessageSame + } +} + @Suppress("MagicNumber") enum class AudioSpeed(val value: Float, @StringRes val titleRes: Int) { NORMAL(1f, R.string.audio_speed_1), @@ -84,27 +122,32 @@ sealed class AudioMediaPlayingState { } sealed class AudioMediaPlayerStateUpdate( + open val conversationId: ConversationId, open val messageId: String ) { data class AudioMediaPlayingStateUpdate( + override val conversationId: ConversationId, override val messageId: String, val audioMediaPlayingState: AudioMediaPlayingState - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) data class PositionChangeUpdate( + override val conversationId: ConversationId, override val messageId: String, val position: Int - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) data class TotalTimeUpdate( + override val conversationId: ConversationId, override val messageId: String, val totalTimeInMs: Int - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) data class WaveMaskUpdate( + override val conversationId: ConversationId, override val messageId: String, val waveMask: List - ) : AudioMediaPlayerStateUpdate(messageId) + ) : AudioMediaPlayerStateUpdate(conversationId, messageId) } sealed class RecordAudioMediaPlayerStateUpdate { diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 53c04bf616..a14f632db9 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -21,130 +21,132 @@ import android.content.Context import android.media.MediaPlayer import android.media.MediaPlayer.SEEK_CLOSEST_SYNC import android.net.Uri +import com.wire.android.R +import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic +import com.wire.android.services.ServicesManager +import com.wire.android.util.extension.intervalFlow +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.asset.MessageAssetResult +import com.wire.kalium.logic.feature.message.GetNextAudioMessageInConversationUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton -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 - private var usageCount: Int = 0 - - @Synchronized - fun provide(): ConversationAudioMessagePlayer { - val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, wavesMaskHelper, coreLogic).also { - player = it - } - usageCount++ - - return player - } - - @Synchronized - fun onCleared() { - usageCount-- - if (usageCount <= 0) { - player?.close() - player = null - } - } -} - @Suppress("TooManyFunctions") class ConversationAudioMessagePlayer -internal constructor( - private val context: Context, +@Inject constructor( + @ApplicationContext private val context: Context, private val audioMediaPlayer: MediaPlayer, private val wavesMaskHelper: AudioWavesMaskHelper, + private val servicesManager: Lazy, + private val audioFocusHelper: AudioFocusHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, + @ApplicationScope private val scope: CoroutineScope ) { private companion object { - const val UPDATE_POSITION_INTERVAL_IN_MS = 100L + const val UPDATE_POSITION_INTERVAL_IN_MS = 1000L } init { audioMediaPlayer.setOnCompletionListener { - if (currentAudioMessageId != null) { - audioMessageStateUpdate.tryEmit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - currentAudioMessageId!!, - AudioMediaPlayingState.Completed - ) - ) - seekToAudioPosition.tryEmit(currentAudioMessageId!! to 0) + currentAudioMessageId?.let { + scope.launch { + if (!tryToPlayNextAudio(it)) { + forceToStopCurrentAudioMessage() + } + } } } - } - private val audioMessageStateUpdate = - MutableSharedFlow( - onBufferOverflow = BufferOverflow.DROP_OLDEST, - extraBufferCapacity = 1 + audioFocusHelper.setListener( + onPauseCurrentAudio = { scope.launch { pauseCurrentAudioMessage() } }, + onResumeCurrentAudio = { scope.launch { resumeCurrentAudioMessage() } } ) + } + + private var audioMessageStateHistory: Map = emptyMap() + private var currentAudioMessageId: MessageIdWrapper? = null + + private val audioMessageStateUpdate = MutableSharedFlow( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + extraBufferCapacity = 1 + ) + + private val _audioSpeed = MutableStateFlow(AudioSpeed.NORMAL) + val audioSpeed: Flow = _audioSpeed.onStart { emit(_audioSpeed.value) } - private val _audioSpeed = MutableSharedFlow( + private val seekToAudioPosition = MutableSharedFlow>( onBufferOverflow = BufferOverflow.DROP_OLDEST, - extraBufferCapacity = 1, - replay = 1 + extraBufferCapacity = 1 ) + private val positionCheckTrigger = MutableSharedFlow() + // MediaPlayer API does not have any mechanism that would inform as about the currentPosition, - // in a callback manner, therefore we need to create a timer manually that ticks every 1 second + // in a callback manner, therefore we need to create a timer manually that ticks every UPDATE_POSITION_INTERVAL_IN_MS // and emits the current position - private val mediaPlayerPosition = flow { - delay(UPDATE_POSITION_INTERVAL_IN_MS) - while (true) { - if (audioMediaPlayer.isPlaying) { - emit(currentAudioMessageId to audioMediaPlayer.currentPosition) + private val mediaPlayerPosition = positionCheckTrigger + .map { + currentAudioMessageId?.let { + audioMessageStateHistory[it]?.let { state -> state.isPlaying() } + } ?: false + } + .distinctUntilChanged() + .flatMapLatest { isAnythingPlaying -> + if (isAnythingPlaying) { + intervalFlow(UPDATE_POSITION_INTERVAL_IN_MS) + .map { + if (audioMediaPlayer.isPlaying) { + currentAudioMessageId to audioMediaPlayer.currentPosition + } else { + null + } + }.filterNotNull() + } else { + // no need for tick-tack checking if there no playing message + emptyFlow>() } - delay(UPDATE_POSITION_INTERVAL_IN_MS) } - }.distinctUntilChanged() - - private val seekToAudioPosition = - MutableSharedFlow>( - onBufferOverflow = BufferOverflow.DROP_OLDEST, - extraBufferCapacity = 1 - ) private val positionChangedUpdate = merge(mediaPlayerPosition, seekToAudioPosition) .map { (messageId, position) -> messageId?.let { - AudioMediaPlayerStateUpdate.PositionChangeUpdate(it, position) + AudioMediaPlayerStateUpdate.PositionChangeUpdate(it.conversationId, it.messageId, position) } }.filterNotNull() - private var audioMessageStateHistory: Map = emptyMap() - // Flow collecting the audio message state updates as well as the audio message position // updates, the collected values are then put into the map holding the state for each individual audio message. // The audio message position can be either updated by the user manually by for example a Slider component or by the player itself. - val observableAudioMessagesState: Flow> = + val observableAudioMessagesState: Flow> = merge(positionChangedUpdate, audioMessageStateUpdate).map { audioMessageStateUpdate -> + val messageIdKey = + MessageIdWrapper(audioMessageStateUpdate.conversationId, audioMessageStateUpdate.messageId) val currentState = audioMessageStateHistory.getOrDefault( - audioMessageStateUpdate.messageId, + messageIdKey, AudioState.DEFAULT ) @@ -152,16 +154,17 @@ internal constructor( is AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy(audioMediaPlayingState = audioMessageStateUpdate.audioMediaPlayingState) ) } + positionCheckTrigger.emit(Unit) } is AudioMediaPlayerStateUpdate.PositionChangeUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy(currentPositionInMs = audioMessageStateUpdate.position) ) } @@ -170,7 +173,7 @@ internal constructor( is AudioMediaPlayerStateUpdate.TotalTimeUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy( totalTimeInMs = AudioState.TotalTimeInMs.Known(audioMessageStateUpdate.totalTimeInMs) ) @@ -181,7 +184,7 @@ internal constructor( is AudioMediaPlayerStateUpdate.WaveMaskUpdate -> { audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { put( - audioMessageStateUpdate.messageId, + messageIdKey, currentState.copy(wavesMask = audioMessageStateUpdate.waveMask) ) } @@ -189,25 +192,58 @@ internal constructor( } audioMessageStateHistory - }.onStart { emit(audioMessageStateHistory) } + }.shareIn(scope, SharingStarted.WhileSubscribed(), 1) + .onStart { emit(audioMessageStateHistory) } + + // Flow contains currently playing or last paused Audio message date. + // If there is such a message state is PlayingAudioMessageState.Some, + // PlayingAudioMessageState.None otherwise + val playingAudioMessageFlow: Flow = observableAudioMessagesState + .scan(PlayingAudioMessage.None as PlayingAudioMessage) { prevState, statesHistory -> + val currentMessageId = currentAudioMessageId + val state = currentMessageId?.let { statesHistory[it] } + + when { + (state?.isPlayingOrPausedOrFetching() != true) -> PlayingAudioMessage.None + + (prevState is PlayingAudioMessage.Some && prevState.messageId == currentMessageId.messageId) -> + // no need to request Sender name if we already have it + PlayingAudioMessage.Some( + conversationId = currentMessageId.conversationId, + messageId = currentMessageId.messageId, + authorName = prevState.authorName, + state = state + ) - val audioSpeed: Flow = _audioSpeed.onStart { emit(AudioSpeed.NORMAL) } + else -> { + val authorName = getSenderNameByMessageId(currentMessageId.conversationId, currentMessageId.messageId) + ?.let { UIText.DynamicString(it) } + ?: UIText.StringResource(R.string.username_unavailable_label) - private var currentAudioMessageId: String? = null + PlayingAudioMessage.Some( + conversationId = currentMessageId.conversationId, + messageId = currentMessageId.messageId, + authorName = authorName, + state = state, + ) + } + } + } + .shareIn(scope, SharingStarted.WhileSubscribed(), 1) suspend fun playAudio( conversationId: ConversationId, - requestedAudioMessageId: String + messageId: String ) { - val isRequestedAudioMessageCurrentlyPlaying = currentAudioMessageId == requestedAudioMessageId + val isRequestedAudioMessageCurrentlyPlaying = currentAudioMessageId == MessageIdWrapper(conversationId, messageId) if (isRequestedAudioMessageCurrentlyPlaying) { - resumeOrPauseCurrentlyPlayingAudioMessage(requestedAudioMessageId) + resumeOrPause(conversationId, messageId) } else { - stopCurrentlyPlayingAudioMessage() + stopCurrentAudioMessage() playAudioMessage( conversationId = conversationId, - messageId = requestedAudioMessageId, - position = previouslyResumedPosition(requestedAudioMessageId) + messageId = messageId, + position = previouslyResumedPosition(conversationId, messageId) ) } } @@ -218,30 +254,15 @@ internal constructor( updateSpeedFlow() } - private fun previouslyResumedPosition(requestedAudioMessageId: String): Int? { - return audioMessageStateHistory[requestedAudioMessageId]?.run { - if (audioMediaPlayingState == AudioMediaPlayingState.Completed) { - 0 - } else { - currentPositionInMs - } - } - } - - private suspend fun stopCurrentlyPlayingAudioMessage() { - currentAudioMessageId?.let { - val currentAudioState = audioMessageStateHistory[it] - if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { - stop(it) - } - } + suspend fun forceToStopCurrentAudioMessage() { + stopCurrentAudioMessage() + servicesManager.get().stopPlayingAudioMessageService() + audioFocusHelper.abandon() } - private suspend fun resumeOrPauseCurrentlyPlayingAudioMessage(messageId: String) { - if (audioMediaPlayer.isPlaying) { - pause(messageId) - } else { - resumeAudio(messageId) + suspend fun resumeOrPauseCurrentAudioMessage() { + currentAudioMessageId?.let { (conversationId, messageId) -> + resumeOrPause(conversationId, messageId) } } @@ -250,79 +271,88 @@ internal constructor( messageId: String, position: Int? = null ) { - currentAudioMessageId = messageId + currentAudioMessageId = MessageIdWrapper(conversationId, messageId) - coroutineScope { - launch { - val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() - if (currentAccountResult is CurrentSessionResult.Failure) return@launch + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Fetching) - ) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Fetching) + ) - val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) + val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) - when (val result = assetMessage.await()) { - is MessageAssetResult.Success -> { - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.SuccessfulFetching - ) - ) + when (val result = assetMessage.await()) { + is MessageAssetResult.Success -> { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.SuccessfulFetching + ) + ) - val isFetchedAudioCurrentlyQueuedToPlay = messageId == currentAudioMessageId + val isFetchedAudioCurrentlyQueuedToPlay = MessageIdWrapper(conversationId, messageId) == currentAudioMessageId - if (isFetchedAudioCurrentlyQueuedToPlay) { - audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) - audioMediaPlayer.prepare() + if (isFetchedAudioCurrentlyQueuedToPlay) { + audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) + audioMediaPlayer.prepare() - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.WaveMaskUpdate( - messageId, - wavesMaskHelper.getWaveMask(result.decodedAssetPath) - ) - ) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + conversationId, + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) + ) + ) - if (position != null) audioMediaPlayer.seekTo(position) + if (position != null) audioMediaPlayer.seekTo(position) - audioMediaPlayer.start() + audioFocusHelper.request() + audioMediaPlayer.start() - updateSpeedFlow() + setSpeed(_audioSpeed.value) - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) - ) + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.Playing + ) + ) - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.TotalTimeUpdate(messageId, audioMediaPlayer.duration) - ) - } - } + servicesManager.get().startPlayingAudioMessageService() - is MessageAssetResult.Failure -> { - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Failed) - ) - } + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.TotalTimeUpdate(conversationId, messageId, audioMediaPlayer.duration) + ) } } + + is MessageAssetResult.Failure -> { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( + conversationId, + messageId, + AudioMediaPlayingState.Failed + ) + ) + } } } - suspend fun setPosition(messageId: String, position: Int) { - val currentAudioState = audioMessageStateHistory[messageId] + suspend fun setPosition(conversationId: ConversationId, messageId: String, position: Int) { + val currentAudioState = audioMessageStateHistory[MessageIdWrapper(conversationId, messageId)] if (currentAudioState != null) { - val isAudioMessageCurrentlyPlaying = currentAudioMessageId == messageId + val isAudioMessageCurrentlyPlaying = currentAudioMessageId == MessageIdWrapper(conversationId, messageId) if (isAudioMessageCurrentlyPlaying) { audioMediaPlayer.seekTo(position.toLong(), SEEK_CLOSEST_SYNC) } } - seekToAudioPosition.emit(messageId to position) + seekToAudioPosition.emit(MessageIdWrapper(conversationId, messageId) to position) } suspend fun fetchWavesMask(conversationId: ConversationId, messageId: String) { @@ -338,6 +368,7 @@ internal constructor( if (result is MessageAssetResult.Success) { audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.WaveMaskUpdate( + conversationId, messageId, wavesMaskHelper.getWaveMask(result.decodedAssetPath) ) @@ -345,6 +376,43 @@ internal constructor( } } + private suspend fun resumeOrPause(conversationId: ConversationId, messageId: String) { + if (audioMediaPlayer.isPlaying) { + pause(conversationId, messageId) + audioFocusHelper.abandon() + } else { + audioFocusHelper.request() + resume(conversationId, messageId) + } + } + + private suspend fun pauseCurrentAudioMessage() { + currentAudioMessageId?.let { + val currentAudioState = audioMessageStateHistory[it] + if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { + pause(it.conversationId, it.messageId) + } + } + } + + private suspend fun resumeCurrentAudioMessage() { + currentAudioMessageId?.let { + val currentAudioState = audioMessageStateHistory[it] + if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { + resume(it.conversationId, it.messageId) + } + } + } + + private suspend fun stopCurrentAudioMessage() { + currentAudioMessageId?.let { + val currentAudioState = audioMessageStateHistory[it] + if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { + stop(it.conversationId, it.messageId) + } + } + } + private suspend fun getAssetMessage( currentAccountResult: CurrentSessionResult, conversationId: ConversationId, @@ -354,29 +422,37 @@ internal constructor( .messages .getAssetMessage(conversationId, messageId) - private suspend fun resumeAudio(messageId: String) { + private suspend fun resume(conversationId: ConversationId, messageId: String) { audioMediaPlayer.start() updateSpeedFlow() audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Playing) ) + servicesManager.get().startPlayingAudioMessageService() } - private suspend fun pause(messageId: String) { + private suspend fun pause(conversationId: ConversationId, messageId: String) { audioMediaPlayer.pause() audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Paused) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Paused) ) } - private suspend fun stop(messageId: String) { + private suspend fun stop(conversationId: ConversationId, messageId: String) { audioMediaPlayer.reset() audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Stopped) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Stopped) ) + + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(conversationId, messageId, AudioMediaPlayingState.Completed) + ) + + seekToAudioPosition.emit(MessageIdWrapper(conversationId, messageId) to 0) + currentAudioMessageId = null } private suspend fun updateSpeedFlow() { @@ -384,8 +460,53 @@ internal constructor( _audioSpeed.emit(currentSpeed) } - internal fun close() { + private suspend fun tryToPlayNextAudio(currentMessageIdWrapper: MessageIdWrapper): Boolean { + val (conversationId, currentMessageId) = currentMessageIdWrapper + + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Success) { + coreLogic + .getSessionScope((currentAccountResult).accountInfo.userId) + .messages + .getNextAudioMessageInConversation(conversationId, currentMessageId).let { nextAudio -> + if (nextAudio is GetNextAudioMessageInConversationUseCase.Result.Success) { + playAudio(conversationId, nextAudio.messageId) + return true + } + } + } + return false + } + + private suspend fun getSenderNameByMessageId(conversationId: ConversationId, messageId: String): String? { + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return null + + val senderNameResult = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getSenderNameByMessageId(conversationId, messageId) + + return if (senderNameResult is GetSenderNameByMessageIdUseCase.Result.Success) senderNameResult.name else null + } + + private fun previouslyResumedPosition(conversationId: ConversationId, requestedAudioMessageId: String): Int? { + return audioMessageStateHistory[MessageIdWrapper(conversationId, requestedAudioMessageId)]?.run { + if (audioMediaPlayingState == AudioMediaPlayingState.Completed) { + 0 + } else { + currentPositionInMs + } + } + } + + internal fun clear() { audioMediaPlayer.reset() wavesMaskHelper.clear() + currentAudioMessageId = null + audioMessageStateHistory = emptyMap() + servicesManager.get().stopPlayingAudioMessageService() } + + data class MessageIdWrapper(val conversationId: ConversationId, val messageId: String) } diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt index af81f1014c..ce2e147695 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt @@ -73,6 +73,8 @@ class NotificationChannelsManager @Inject constructor( // OngoingCall is not user specific channel, but common for all users. createOngoingNotificationChannel() + createPlayingAudioMessageNotificationChannel() + deleteRedundantChannelGroups(allUsers) } @@ -151,6 +153,20 @@ class NotificationChannelsManager @Inject constructor( notificationManagerCompat.createNotificationChannel(notificationChannel) } + private fun createPlayingAudioMessageNotificationChannel() { + val channelId = NotificationConstants.PLAYING_AUDIO_CHANNEL_ID + val notificationChannel = NotificationChannelCompat + .Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(NotificationConstants.PLAYING_AUDIO_CHANNEL_NAME) + .setVibrationEnabled(false) + .setImportance(NotificationManagerCompat.IMPORTANCE_LOW) + .setSound(null, null) + .setShowBadge(false) + .build() + + notificationManagerCompat.createNotificationChannel(notificationChannel) + } + private fun createMessagesNotificationChannel(userId: UserId, channelGroupId: String) { val notificationChannel = NotificationChannelCompat .Builder(NotificationConstants.getMessagesChannelId(userId), NotificationManagerCompat.IMPORTANCE_HIGH) diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt index b67a4b702a..80660e36fd 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt @@ -29,6 +29,8 @@ object NotificationConstants { const val OUTGOING_CALL_CHANNEL_NAME = "Outgoing call" const val ONGOING_CALL_CHANNEL_ID = "com.wire.android.notification_ongoing_call_channel" const val ONGOING_CALL_CHANNEL_NAME = "Ongoing calls" + const val PLAYING_AUDIO_CHANNEL_ID = "com.wire.android.notification_playing_audio_message_channel" + const val PLAYING_AUDIO_CHANNEL_NAME = "Playing Audio Message" const val WEB_SOCKET_CHANNEL_ID = "com.wire.android.persistent_web_socket_channel" const val WEB_SOCKET_CHANNEL_NAME = "Persistent WebSocket" @@ -90,5 +92,6 @@ enum class NotificationIds { MESSAGE_SYNC_NOTIFICATION_ID, MIGRATION_NOTIFICATION_ID, SINGLE_USER_MIGRATION_NOTIFICATION_ID, - MIGRATION_ERROR_NOTIFICATION_ID + MIGRATION_ERROR_NOTIFICATION_ID, + PLAYING_AUDIO_MESSAGE_ID } diff --git a/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt b/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt index 363dadd2f0..16a89747d1 100644 --- a/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt +++ b/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt @@ -31,6 +31,8 @@ import androidx.core.content.ContextCompat import com.wire.android.notification.broadcastreceivers.EndOngoingCallReceiver import com.wire.android.notification.broadcastreceivers.IncomingCallActionReceiver import com.wire.android.notification.broadcastreceivers.NotificationReplyReceiver +import com.wire.android.notification.broadcastreceivers.PlayPauseAudioMessageReceiver +import com.wire.android.notification.broadcastreceivers.StopAudioMessageReceiver import com.wire.android.ui.WireActivity import com.wire.android.ui.calling.CallActivity.Companion.EXTRA_CONVERSATION_ID import com.wire.android.ui.calling.CallActivity.Companion.EXTRA_SCREEN_TYPE @@ -220,6 +222,28 @@ fun openAppPendingIntent(context: Context): PendingIntent { ) } +fun playPauseAudioPendingIntent(context: Context): PendingIntent { + val intent = PlayPauseAudioMessageReceiver.newIntent(context) + + return PendingIntent.getBroadcast( + context.applicationContext, + getRequestCode("", PLAY_PAUSE_AUDIO_REQUEST_CODE), + intent, + PendingIntent.FLAG_IMMUTABLE + ) +} + +fun stopAudioPendingIntent(context: Context): PendingIntent { + val intent = StopAudioMessageReceiver.newIntent(context) + + return PendingIntent.getBroadcast( + context.applicationContext, + getRequestCode("", STOP_AUDIO_REQUEST_CODE), + intent, + PendingIntent.FLAG_IMMUTABLE + ) +} + private const val MESSAGE_NOTIFICATIONS_SUMMARY_REQUEST_CODE = 0 private const val DECLINE_CALL_REQUEST_CODE = "decline_call_" private const val ANSWER_CALL_REQUEST_CODE = "answer_call_" @@ -228,6 +252,8 @@ private const val OPEN_ONGOING_CALL_REQUEST_CODE = 4 private const val OPEN_MIGRATION_LOGIN_REQUEST_CODE = 5 private const val OUTGOING_CALL_REQUEST_CODE = 6 private const val END_ONGOING_CALL_REQUEST_CODE = "hang_up_call_" +private const val PLAY_PAUSE_AUDIO_REQUEST_CODE = "play_or_pause_audio_" +private const val STOP_AUDIO_REQUEST_CODE = "stop_audio_" private const val OPEN_MESSAGE_REQUEST_CODE_PREFIX = "open_message_" private const val OPEN_OTHER_USER_PROFILE_CODE_PREFIX = "open_other_user_profile_" private const val REPLY_MESSAGE_REQUEST_CODE_PREFIX = "reply_" diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/PlayPauseAudioMessageReceiver.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/PlayPauseAudioMessageReceiver.kt new file mode 100644 index 0000000000..9cb6998f40 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/PlayPauseAudioMessageReceiver.kt @@ -0,0 +1,54 @@ +/* + * 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.notification.broadcastreceivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.wire.android.appLogger +import com.wire.android.di.ApplicationScope +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class PlayPauseAudioMessageReceiver : BroadcastReceiver() { + + @Inject + lateinit var audioMessagePlayer: ConversationAudioMessagePlayer + + @Inject + @ApplicationScope + lateinit var coroutineScope: CoroutineScope + + override fun onReceive(context: Context, intent: Intent) { + appLogger.i("PlayPauseAudioMessageReceiver: onReceive") + + coroutineScope.launch { + audioMessagePlayer.resumeOrPauseCurrentAudioMessage() + } + } + + companion object { + fun newIntent(context: Context): Intent = + Intent(context, PlayPauseAudioMessageReceiver::class.java) + } +} diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/StopAudioMessageReceiver.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/StopAudioMessageReceiver.kt new file mode 100644 index 0000000000..8ffe59f64e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/StopAudioMessageReceiver.kt @@ -0,0 +1,53 @@ +/* + * 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.notification.broadcastreceivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.wire.android.appLogger +import com.wire.android.di.ApplicationScope +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class StopAudioMessageReceiver : BroadcastReceiver() { + + @Inject + lateinit var audioMessagePlayer: ConversationAudioMessagePlayer + + @Inject + @ApplicationScope + lateinit var coroutineScope: CoroutineScope + + override fun onReceive(context: Context, intent: Intent) { + appLogger.i("StopAudioMessageReceiver: onReceive") + coroutineScope.launch { + audioMessagePlayer.forceToStopCurrentAudioMessage() + } + } + + companion object { + fun newIntent(context: Context): Intent = + Intent(context, StopAudioMessageReceiver::class.java) + } +} diff --git a/app/src/main/kotlin/com/wire/android/services/PlayingAudioMessageService.kt b/app/src/main/kotlin/com/wire/android/services/PlayingAudioMessageService.kt new file mode 100644 index 0000000000..fe5a5c18ab --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/services/PlayingAudioMessageService.kt @@ -0,0 +1,173 @@ +/* + * 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.services + +import android.app.ForegroundServiceStartNotAllowedException +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.drawable.Icon +import android.os.Build +import android.os.IBinder +import android.view.View +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import com.wire.android.R +import com.wire.android.appLogger +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.PlayingAudioMessage +import com.wire.android.notification.NotificationConstants.PLAYING_AUDIO_CHANNEL_ID +import com.wire.android.notification.NotificationIds +import com.wire.android.notification.openAppPendingIntent +import com.wire.android.notification.playPauseAudioPendingIntent +import com.wire.android.notification.stopAudioPendingIntent +import com.wire.android.util.dispatchers.DispatcherProvider +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class PlayingAudioMessageService : Service() { + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + private val scope by lazy { + CoroutineScope(SupervisorJob() + dispatcherProvider.io()) + } + + @Inject + lateinit var audioMessagePlayer: ConversationAudioMessagePlayer + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onCreate() { + super.onCreate() + appLogger.i("$TAG: starting foreground") + isServiceStarted = true + generateForegroundNotification(null) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + /** + * When service is restarted by system onCreate lifecycle method is not guaranteed to be called + * so we need to check if service is already started and if not generate notification and call startForeground() + * https://issuetracker.google.com/issues/307329994#comment100 + */ + if (!isServiceStarted) { + appLogger.i("$TAG: already started") + isServiceStarted = true + generateForegroundNotification(null) + } + scope.launch { + audioMessagePlayer.playingAudioMessageFlow + .distinctUntilChanged { old, new -> old.isSameAs(new) } + .collectLatest { playingAudioMessage -> + if (playingAudioMessage is PlayingAudioMessage.Some) { + generateForegroundNotification(playingAudioMessage) + } else { + generateForegroundNotification(null) + } + } + } + return START_STICKY + } + + private fun generateForegroundNotification(audio: PlayingAudioMessage.Some?) { + val notification = getNotification(audio) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + ServiceCompat.startForeground( + this, + NotificationIds.PLAYING_AUDIO_MESSAGE_ID.ordinal, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } catch (e: ForegroundServiceStartNotAllowedException) { + // ForegroundServiceStartNotAllowedException may be thrown on restarting service from the background. + // this is the only suggested workaround from google for now. + // https://issuetracker.google.com/issues/307329994#comment86 + appLogger.e("$TAG: Failure while starting foreground: $e") + stopSelf() + } + } else { + ServiceCompat.startForeground( + this, + NotificationIds.PLAYING_AUDIO_MESSAGE_ID.ordinal, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } + } + + private fun getNotification(audio: PlayingAudioMessage.Some?): Notification { + val playPauseVisibility = if (audio == null) View.GONE else View.VISIBLE + val playPauseIconRes = if (audio?.state?.isPlaying() == true) R.drawable.ic_pause else R.drawable.ic_play + val btnColor = this.getColor(R.color.default_icon_color) + val playPauseIcon = Icon.createWithResource(this, playPauseIconRes).setTint(btnColor) + val stopIcon = Icon.createWithResource(this, R.drawable.ic_stop).setTint(btnColor) + val senderName = audio?.authorName?.asString(this.resources) + val stopAudioPendingIntent = stopAudioPendingIntent(this) + val playPauseAudioPendingIntent = playPauseAudioPendingIntent(this) + + val notificationLayout = RemoteViews(packageName, R.layout.notification_playing_audio_message).apply { + setTextViewText(R.id.title, senderName) + setImageViewIcon(R.id.play_pause, playPauseIcon) + setViewVisibility(R.id.play_pause, playPauseVisibility) + setImageViewIcon(R.id.stop, stopIcon) + setOnClickPendingIntent(R.id.stop, stopAudioPendingIntent) + setOnClickPendingIntent(R.id.play_pause, playPauseAudioPendingIntent) + } + + return NotificationCompat.Builder(this, PLAYING_AUDIO_CHANNEL_ID) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setSmallIcon(R.drawable.notification_icon_small) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setCustomContentView(notificationLayout) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(openAppPendingIntent(this)) + .build() + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel("$TAG was destroyed") + isServiceStarted = false + appLogger.i("$TAG: was destroyed") + } + + companion object { + private const val TAG = "PlayingAudioMessageService" + fun newIntent(context: Context?): Intent = + Intent(context, PlayingAudioMessageService::class.java) + + var isServiceStarted = false + } +} diff --git a/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt b/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt index aa4d365be6..8376f3dbe1 100644 --- a/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt +++ b/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt @@ -58,7 +58,7 @@ class ServicesManager @Inject constructor( .distinctUntilChanged() .collectLatest { action -> if (action is CallService.Action.Stop) { - appLogger.i("ServicesManager: stopping CallService because there are no calls") + appLogger.i("$TAG: stopping CallService because there are no calls") when (CallService.serviceState.get()) { CallService.ServiceState.STARTED -> { // Instead of simply calling stopService(CallService::class), which can end up with a crash if it @@ -67,21 +67,21 @@ class ServicesManager @Inject constructor( // This way, when this service is killed and recreated by the system, it will stop itself right after // recreating so it won't cause any problems. startService(CallService.newIntent(context, CallService.Action.Stop)) - appLogger.i("ServicesManager: CallService stopped by passing stop argument") + appLogger.i("$TAG: CallService stopped by passing stop argument") } CallService.ServiceState.FOREGROUND -> { // we can just stop the service, because it's already in foreground context.stopService(CallService.newIntent(context)) - appLogger.i("ServicesManager: CallService stopped by calling stopService") + appLogger.i("$TAG: CallService stopped by calling stopService") } else -> { - appLogger.i("ServicesManager: CallService not running, nothing to stop") + appLogger.i("$TAG: CallService not running, nothing to stop") } } } else { - appLogger.i("ServicesManager: starting CallService") + appLogger.i("$TAG: starting CallService") startService(CallService.newIntent(context, action)) } } @@ -89,7 +89,7 @@ class ServicesManager @Inject constructor( } fun startCallService() { - appLogger.i("ServicesManager: start CallService event") + appLogger.i("$TAG: start CallService event") scope.launch { callServiceEvents.emit(CallService.Action.Default) } @@ -103,7 +103,7 @@ class ServicesManager @Inject constructor( } fun stopCallService() { - appLogger.i("ServicesManager: stop CallService event") + appLogger.i("$TAG: stop CallService event") scope.launch { callServiceEvents.emit(CallService.Action.Stop) } @@ -112,7 +112,7 @@ class ServicesManager @Inject constructor( // Persistent WebSocket fun startPersistentWebSocketService() { if (PersistentWebSocketService.isServiceStarted) { - appLogger.i("ServicesManager: PersistentWebsocketService already started, not starting again") + appLogger.i("$TAG: PersistentWebsocketService already started, not starting again") } else { startService(PersistentWebSocketService.newIntent(context)) } @@ -125,8 +125,21 @@ class ServicesManager @Inject constructor( fun isPersistentWebSocketServiceRunning(): Boolean = PersistentWebSocketService.isServiceStarted + // Playing AudioMessage service + fun startPlayingAudioMessageService() { + if (PlayingAudioMessageService.isServiceStarted) { + appLogger.i("$TAG: PlayingAudioMessageService already started, not starting again") + } else { + startService(PlayingAudioMessageService.newIntent(context)) + } + } + + fun stopPlayingAudioMessageService() { + stopService(PlayingAudioMessageService.newIntent(context)) + } + private fun startService(intent: Intent) { - appLogger.i("ServicesManager: starting service for $intent") + appLogger.i("$TAG: starting service for $intent") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) } else { @@ -135,11 +148,13 @@ class ServicesManager @Inject constructor( } private fun stopService(intent: Intent) { - appLogger.i("ServicesManager: stopping service for $intent") + appLogger.i("$TAG: stopping service for $intent") context.stopService(intent) } companion object { + private const val TAG = "ServicesManager" + @VisibleForTesting const val DEBOUNCE_TIME = 500L } 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 d624f997f1..b086bb7bdc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -93,6 +93,7 @@ import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs import com.wire.android.mapper.MessageDateTimeGroup import com.wire.android.media.audiomessage.AudioSpeed +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand @@ -146,7 +147,6 @@ import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBack import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState -import com.wire.android.ui.home.conversations.messages.PlayingAudiMessage import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem @@ -1417,58 +1417,58 @@ fun JumpToLastMessageButton( @Composable fun BoxScope.JumpToPlayingAudioButton( lazyListState: LazyListState, - playingAudiMessage: PlayingAudiMessage?, + playingAudiMessage: PlayingAudioMessage, lazyPagingMessages: LazyPagingItems, modifier: Modifier = Modifier, coroutineScope: CoroutineScope = rememberCoroutineScope() ) { - val indexOfPlayedMessage = playingAudiMessage?.let { - lazyPagingMessages.itemSnapshotList + if (playingAudiMessage is PlayingAudioMessage.Some && playingAudiMessage.state.isPlaying()) { + val indexOfPlayedMessage = lazyPagingMessages.itemSnapshotList .indexOfFirst { playingAudiMessage.messageId == it?.header?.messageId } - } ?: -1 - if (indexOfPlayedMessage < 0) return + if (indexOfPlayedMessage < 0) return - val firstVisibleIndex = lazyListState.firstVisibleItemIndex - val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: firstVisibleIndex + val firstVisibleIndex = lazyListState.firstVisibleItemIndex + val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: firstVisibleIndex - if (indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex) return + if (indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex) return - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .wrapContentWidth() - .align(Alignment.TopCenter) - .padding(all = dimensions().spacing8x) - .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } - .background( - color = colorsScheme().secondaryText, - shape = RoundedCornerShape(dimensions().corner16x) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .wrapContentWidth() + .align(Alignment.TopCenter) + .padding(all = dimensions().spacing8x) + .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } + .background( + color = colorsScheme().secondaryText, + shape = RoundedCornerShape(dimensions().corner16x) + ) + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) + ) { + Icon( + modifier = Modifier.size(dimensions().systemMessageIconSize), + painter = painterResource(id = R.drawable.ic_play), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled ) - .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) - ) { - Icon( - modifier = Modifier.size(dimensions().systemMessageIconSize), - painter = painterResource(id = R.drawable.ic_play), - contentDescription = null, - tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled - ) - Text( - modifier = Modifier - .padding(horizontal = dimensions().spacing8x) - .weight(1f, fill = false), - text = playingAudiMessage!!.authorName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = colorsScheme().onPrimaryButtonEnabled, - style = MaterialTheme.wireTypography.body04, - ) - Text( - modifier = Modifier, - text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.currentTimeMs.toLong()), - color = colorsScheme().onPrimaryButtonEnabled, - style = MaterialTheme.wireTypography.label03, - ) + Text( + modifier = Modifier + .padding(horizontal = dimensions().spacing8x) + .weight(1f, fill = false), + text = playingAudiMessage.authorName.asString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.body04, + ) + Text( + modifier = Modifier, + text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.state.currentPositionInMs.toLong()), + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.label03, + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index 5377c40f8c..9904bd653e 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,9 +28,8 @@ import androidx.paging.PagingData import androidx.paging.map import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioSpeed -import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.SavedStateViewModel import com.wire.android.ui.home.conversations.ConversationNavArgs @@ -65,7 +64,6 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase -import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -79,8 +77,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -106,18 +104,16 @@ class ConversationMessagesViewModel @Inject constructor( private val getMessageForConversation: GetMessagesForConversationUseCase, private val toggleReaction: ToggleReactionUseCase, private val resetSession: ResetSessionUseCase, - private val conversationAudioMessagePlayerProvider: ConversationAudioMessagePlayerProvider, + private val audioMessagePlayer: ConversationAudioMessagePlayer, private val getConversationUnreadEventsCount: GetConversationUnreadEventsCountUseCase, private val clearUsersTypingEvents: ClearUsersTypingEventsUseCase, private val getSearchedConversationMessagePosition: GetSearchedConversationMessagePositionUseCase, private val deleteMessage: DeleteMessageUseCase, - private val getSenderNameByMessageId: GetSenderNameByMessageIdUseCase ) : SavedStateViewModel(savedStateHandle) { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() val conversationId: QualifiedID = conversationNavArgs.conversationId private val searchedMessageIdNavArgs: String? = conversationNavArgs.searchedMessageId - private val conversationAudioMessagePlayer = conversationAudioMessagePlayerProvider.provide() var conversationViewState by mutableStateOf( ConversationMessagesViewState( @@ -190,41 +186,19 @@ class ConversationMessagesViewModel @Inject constructor( } private fun observeAudioPlayerState() { - val observableAudioMessagesState = conversationAudioMessagePlayer.observableAudioMessagesState - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) - - val playingMessageData = observableAudioMessagesState - .map { audioMessageStates -> - audioMessageStates.firstNotNullOfOrNull { (messageId, audioState) -> - if (audioState.audioMediaPlayingState == AudioMediaPlayingState.Playing) messageId else null - } - } - .distinctUntilChanged() - .map { messageId -> - val senderName = messageId?.let { - val result = getSenderNameByMessageId(conversationId, it) - if (result is GetSenderNameByMessageIdUseCase.Result.Success) result.name else null - } - - messageId to senderName - } + val observableAudioMessagesState = audioMessagePlayer.observableAudioMessagesState + .map { audioMessageStates -> audioMessageStates.mapKeys { it.key.messageId } } +// .shareIn(viewModelScope, SharingStarted.Eagerly) viewModelScope.launch { combine( observableAudioMessagesState, - conversationAudioMessagePlayer.audioSpeed, - playingMessageData - ) { audioMessageStates, audioSpeed, (playingMessageId, playingMessageSenderName) -> - val playingAudiMessage = playingMessageId?.let { - PlayingAudiMessage( - messageId = playingMessageId, - authorName = playingMessageSenderName.orEmpty(), - currentTimeMs = audioMessageStates[playingMessageId]?.currentPositionInMs ?: 0 - ) - } + audioMessagePlayer.audioSpeed, + audioMessagePlayer.playingAudioMessageFlow + ) { audioMessageStates, audioSpeed, playingAudiMessage -> AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed, playingAudiMessage) } - .collect { conversationViewState = conversationViewState.copy(audioMessagesState = it) } + .collectLatest { conversationViewState = conversationViewState.copy(audioMessagesState = it) } } } @@ -426,19 +400,19 @@ class ConversationMessagesViewModel @Inject constructor( fun audioClick(messageId: String) { viewModelScope.launch { - conversationAudioMessagePlayer.playAudio(conversationId, messageId) + audioMessagePlayer.playAudio(conversationId, messageId) } } fun changeAudioPosition(messageId: String, position: Int) { viewModelScope.launch { - conversationAudioMessagePlayer.setPosition(messageId, position) + audioMessagePlayer.setPosition(conversationId, messageId, position) } } fun changeAudioSpeed(audioSpeed: AudioSpeed) { viewModelScope.launch { - conversationAudioMessagePlayer.setSpeed(audioSpeed) + audioMessagePlayer.setSpeed(audioSpeed) } } @@ -486,18 +460,13 @@ class ConversationMessagesViewModel @Inject constructor( it.map { message -> if (message.messageContent is UIMessageContent.AudioAssetMessage) { viewModelScope.launch { - conversationAudioMessagePlayer.fetchWavesMask(conversationId, message.header.messageId) + audioMessagePlayer.fetchWavesMask(conversationId, message.header.messageId) } } message } } - override fun onCleared() { - super.onCleared() - conversationAudioMessagePlayerProvider.onCleared() - } - private companion object { const val DEFAULT_ASSET_NAME = "Wire File" const val CURRENT_TIME_REFRESH_WINDOW_IN_MILLIS: Long = 60_000 diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 119139691d..45ef36f5b4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.conversations.messages import androidx.paging.PagingData import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.message.MessageAssetStatus @@ -43,13 +44,7 @@ data class ConversationMessagesViewState( data class AudioMessagesState( val audioStates: PersistentMap = persistentMapOf(), val audioSpeed: AudioSpeed = AudioSpeed.NORMAL, - val playingAudiMessage: PlayingAudiMessage? = null -) - -data class PlayingAudiMessage( - val messageId: String, - val authorName: String, - val currentTimeMs: Int + val playingAudiMessage: PlayingAudioMessage = PlayingAudioMessage.None ) sealed class DownloadedAssetDialogVisibilityState { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index d8e208486a..9566ee9066 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -25,6 +25,7 @@ import androidx.paging.PagingData import androidx.paging.map import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents @@ -48,12 +49,14 @@ class GetConversationsFromSearchUseCase @Inject constructor( private val dispatchers: DispatcherProvider, private val getSelfUser: GetSelfUserUseCase ) { + @Suppress("LongParameterList") suspend operator fun invoke( searchQuery: String = "", fromArchive: Boolean = false, newActivitiesOnTop: Boolean = false, onlyInteractionEnabled: Boolean = false, - conversationFilter: ConversationFilter = ConversationFilter.All + conversationFilter: ConversationFilter = ConversationFilter.All, + playingAudioMessage: PlayingAudioMessage = PlayingAudioMessage.None ): Flow> { val pagingConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -95,7 +98,8 @@ class GetConversationsFromSearchUseCase @Inject constructor( it.toConversationItem( userTypeMapper = userTypeMapper, searchQuery = searchQuery, - selfUserTeamId = getSelfUser()?.teamId + selfUserTeamId = getSelfUser()?.teamId, + playingAudioMessage = playingAudioMessage ) } }.flowOn(dispatchers.io()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index e7711b1d8d..5010f3992e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -33,6 +33,7 @@ import com.wire.android.appLogger import com.wire.android.di.CurrentAccount import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.model.SnackBarMessage import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail import com.wire.android.ui.common.dialogs.BlockUserDialogState @@ -98,6 +99,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import java.util.Date +@Suppress("TooManyFunctions") interface ConversationListViewModel { val infoMessage: SharedFlow get() = MutableSharedFlow() val closeBottomSheet: SharedFlow get() = MutableSharedFlow() @@ -120,8 +122,11 @@ interface ConversationListViewModel { fun muteConversation(conversationId: ConversationId?, mutedConversationStatus: MutedConversationStatus) {} fun moveConversationToFolder() {} fun searchQueryChanged(searchQuery: String) {} + fun playPauseCurrentAudio() {} + fun stopCurrentAudio() {} } +@Suppress("TooManyFunctions") class ConversationListViewModelPreview( foldersWithConversations: Flow> = previewConversationFoldersFlow(), ) : ConversationListViewModel { @@ -147,6 +152,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val refreshConversationsWithoutMetadata: RefreshConversationsWithoutMetadataUseCase, private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase, private val observeLegalHoldStateForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, + private val audioMessagePlayer: ConversationAudioMessagePlayer, @CurrentAccount val currentAccount: UserId, private val userTypeMapper: UserTypeMapper, private val getSelfUser: GetSelfUserUseCase, @@ -187,13 +193,17 @@ class ConversationListViewModelImpl @AssistedInject constructor( .onStart { emit("") } .combine(isSelfUserUnderLegalHoldFlow, ::Pair) .distinctUntilChanged() - .flatMapLatest { (searchQuery, isSelfUserUnderLegalHold) -> + .combine(audioMessagePlayer.playingAudioMessageFlow) { (searchQuery, isSelfUserUnderLegalHold), playingAudioMessage -> + Triple(searchQuery, isSelfUserUnderLegalHold, playingAudioMessage) + } + .flatMapLatest { (searchQuery, isSelfUserUnderLegalHold, playingAudioMessage) -> getConversationsPaginated( searchQuery = searchQuery, fromArchive = conversationsSource == ConversationsSource.ARCHIVE, conversationFilter = conversationsSource.toFilter(), onlyInteractionEnabled = false, newActivitiesOnTop = containsNewActivitiesSection, + playingAudioMessage = playingAudioMessage ).map { pagingData -> pagingData .map { it.hideIndicatorForSelfUserUnderLegalHold(isSelfUserUnderLegalHold) } @@ -253,15 +263,20 @@ class ConversationListViewModelImpl @AssistedInject constructor( .onStart { emit("") } .distinctUntilChanged() .flatMapLatest { searchQuery: String -> - observeConversationListDetailsWithEvents( - fromArchive = conversationsSource == ConversationsSource.ARCHIVE, - conversationFilter = conversationsSource.toFilter() - ).combine(isSelfUserUnderLegalHoldFlow) { conversations, isSelfUserUnderLegalHold -> + combine( + observeConversationListDetailsWithEvents( + fromArchive = conversationsSource == ConversationsSource.ARCHIVE, + conversationFilter = conversationsSource.toFilter() + ), + isSelfUserUnderLegalHoldFlow, + audioMessagePlayer.playingAudioMessageFlow + ) { conversations, isSelfUserUnderLegalHold, playingAudioMessage -> conversations.map { conversationDetails -> conversationDetails.toConversationItem( userTypeMapper = userTypeMapper, searchQuery = searchQuery, - selfUserTeamId = getSelfUser()?.teamId + selfUserTeamId = getSelfUser()?.teamId, + playingAudioMessage = playingAudioMessage ).hideIndicatorForSelfUserUnderLegalHold(isSelfUserUnderLegalHold) } to searchQuery } @@ -468,6 +483,18 @@ class ConversationListViewModelImpl @AssistedInject constructor( } } + override fun playPauseCurrentAudio() { + viewModelScope.launch { + audioMessagePlayer.resumeOrPauseCurrentAudioMessage() + } + } + + override fun stopCurrentAudio() { + viewModelScope.launch { + audioMessagePlayer.forceToStopCurrentAudioMessage() + } + } + @Suppress("MultiLineIfElse") private suspend fun clearContentSnackbarResult( clearContentResult: ClearConversationContentUseCase.Result, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index feba6cc4e5..f6d885abeb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -85,7 +85,7 @@ import com.wire.kalium.logic.data.user.UserId * This is a base for creating screens for displaying list of conversations. * Can be used to create proper navigation destination for different sources of conversations, like archive. */ -@Suppress("ComplexMethod", "NestedBlockDepth") +@Suppress("ComplexMethod", "NestedBlockDepth", "Wrapping") @Composable fun ConversationsScreenContent( navigator: Navigator, @@ -189,6 +189,18 @@ fun ConversationsScreenContent( } } + val onPlayPauseCurrentAudio: () -> Unit = remember { + { + conversationListViewModel.playPauseCurrentAudio() + } + } + + val onStopCurrentAudio: () -> Unit = remember { + { + conversationListViewModel.stopCurrentAudio() + } + } + when (val state = conversationListViewModel.conversationListState) { is ConversationListState.Paginated -> { val lazyPagingItems = state.conversations.collectAsLazyPagingItems() @@ -212,7 +224,10 @@ fun ConversationsScreenContent( R.string.call_permission_dialog_description ) ) - } + }, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio + ) // when there is no conversation in any folder searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked) @@ -239,7 +254,9 @@ fun ConversationsScreenContent( R.string.call_permission_dialog_description ) ) - } + }, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) // when there is no conversation in any folder searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index a697397f46..c719b28acd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -15,10 +15,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("TooManyFunctions") package com.wire.android.ui.home.conversationslist.common +import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -27,11 +30,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import com.wire.android.R import com.wire.android.model.Clickable @@ -49,9 +57,11 @@ import com.wire.android.ui.home.conversationslist.model.BadgeEventType import com.wire.android.ui.home.conversationslist.model.BlockingState import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem +import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation import com.wire.android.ui.home.conversationslist.model.toUserInfoLabel import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.android.util.ui.toUIText @@ -72,7 +82,9 @@ fun ConversationItemFactory( openMenu: (ConversationItem) -> Unit = {}, openUserProfile: (UserId) -> Unit = {}, joinCall: (ConversationId) -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit = {} + onAudioPermissionPermanentlyDenied: () -> Unit = {}, + onPlayPauseCurrentAudio: () -> Unit = { }, + onStopCurrentAudio: () -> Unit = {} ) { val openConversationOptionDescription = stringResource(R.string.content_description_conversation_details_more_btn) val openUserProfileDescription = stringResource(R.string.content_description_open_user_profile_label) @@ -133,7 +145,9 @@ fun ConversationItemFactory( onJoinCallClick = { joinCall(conversation.conversationId) }, - onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied + onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) } @@ -148,7 +162,9 @@ private fun GeneralConversationItem( modifier: Modifier = Modifier, selectOnRadioGroup: () -> Unit = {}, subTitle: @Composable () -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit + onAudioPermissionPermanentlyDenied: () -> Unit, + onPlayPauseCurrentAudio: () -> Unit = { }, + onStopCurrentAudio: () -> Unit = {} ) { when (conversation) { is ConversationItem.GroupConversation -> { @@ -181,6 +197,12 @@ private fun GeneralConversationItem( buttonClick = onJoinCallClick, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, ) + } else if (conversation.playingAudio != null) { + AudioControlButtons( + playingAudio = conversation.playingAudio!!, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio + ) } else { Row( modifier = Modifier.padding(horizontal = dimensions().spacing8x), @@ -222,14 +244,22 @@ private fun GeneralConversationItem( clickable = onConversationItemClick, trailingIcon = { if (!isSelectable) { - Row( - modifier = Modifier.padding(horizontal = dimensions().spacing8x), - horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) - ) { - if (mutedStatus != MutedConversationStatus.AllAllowed) { - MutedConversationBadge() + if (conversation.playingAudio != null) { + AudioControlButtons( + playingAudio = conversation.playingAudio!!, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio + ) + } else { + Row( + modifier = Modifier.padding(horizontal = dimensions().spacing8x), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + if (mutedStatus != MutedConversationStatus.AllAllowed) { + MutedConversationBadge() + } + EventBadgeFactory(eventType = conversation.badgeEventType) } - EventBadgeFactory(eventType = conversation.badgeEventType) } } } @@ -262,6 +292,52 @@ private fun GeneralConversationItem( } } +@Composable +fun AudioControlButtons( + playingAudio: PlayingAudioInConversation, + modifier: Modifier = Modifier, + onPlayPauseCurrentAudio: () -> Unit = {}, + onStopCurrentAudio: () -> Unit = {} +) { + Row(modifier = modifier.padding(end = dimensions().spacing8x)) { + val playPauseIconId = if (playingAudio.isPaused) R.drawable.ic_play else R.drawable.ic_pause + + val leftBtnShape = RoundedCornerShape(topStart = dimensions().corner16x, bottomStart = dimensions().corner16x) + val rightBtnShape = RoundedCornerShape(topEnd = dimensions().corner16x, bottomEnd = dimensions().corner16x) + Image( + painter = painterResource(id = playPauseIconId), + contentDescription = null, // TODO + modifier = Modifier + .clip(leftBtnShape) + .clickable(role = Role.Button, onClick = onPlayPauseCurrentAudio) + .border( + width = dimensions().spacing1x, + shape = leftBtnShape, + color = colorsScheme().secondaryButtonDisabledOutline + ) + .size(dimensions().buttonSmallMinSize) + .padding(vertical = dimensions().spacing10x, horizontal = dimensions().spacing14x), + colorFilter = ColorFilter.tint(MaterialTheme.wireColorScheme.onSecondaryButtonEnabled) + ) + + Image( + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = null, // TODO + modifier = Modifier + .clip(rightBtnShape) + .clickable(role = Role.Button) { onStopCurrentAudio() } + .border( + width = dimensions().spacing1x, + shape = rightBtnShape, + color = colorsScheme().secondaryButtonDisabledOutline + ) + .size(dimensions().buttonSmallMinSize) + .padding(vertical = dimensions().spacing10x, horizontal = dimensions().spacing14x), + colorFilter = ColorFilter.tint(MaterialTheme.wireColorScheme.onSecondaryButtonEnabled) + ) + } +} + @Composable fun LoadingConversationItem(modifier: Modifier = Modifier) { RowItemTemplate( @@ -323,7 +399,8 @@ fun PreviewGroupConversationItemWithUnreadCount() = WireTheme { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - folder = null + folder = null, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -351,7 +428,8 @@ fun PreviewGroupConversationItemWithNoBadges() = WireTheme { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - folder = null + folder = null, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -381,7 +459,8 @@ fun PreviewGroupConversationItemWithLastDeletedMessage() = WireTheme { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - folder = null + folder = null, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -409,7 +488,8 @@ fun PreviewGroupConversationItemWithMutedBadgeAndUnreadMentionBadge() = WireThem mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - folder = null + folder = null, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -438,7 +518,8 @@ fun PreviewGroupConversationItemWithOngoingCall() = WireTheme { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - folder = null + folder = null, + playingAudio = null ), modifier = Modifier, isSelectableItem = false, @@ -523,7 +604,37 @@ fun PreviewPrivateConversationItemWithBlockedBadge() = WireTheme { proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, isUserDeleted = false, - folder = null + folder = null, + playingAudio = null + ), + modifier = Modifier, + isSelectableItem = false, + isChecked = false, + {}, {}, {}, {}, {}, {} + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewPrivateConversationItemWithPlayingAudio() = WireTheme { + ConversationItemFactory( + conversation = ConversationItem.GroupConversation( + "groupName looooooooooooooooooooooooooooooooooooong", + conversationId = QualifiedID("value", "domain"), + mutedStatus = MutedConversationStatus.OnlyMentionsAndRepliesAllowed, + lastMessageContent = UILastMessageContent.TextMessage( + MessageBody(UIText.DynamicString("Very looooooooooooooooooooooong messageeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")) + ), + badgeEventType = BadgeEventType.UnreadMention, + selfMemberRole = null, + teamId = null, + isArchived = false, + isFromTheSameTeam = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false, + folder = null, + playingAudio = PlayingAudioInConversation("some_id", true) ), modifier = Modifier, isSelectableItem = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt index ed4ac60368..32db159f00 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt @@ -73,7 +73,9 @@ fun ConversationList( onOpenUserProfile: (UserId) -> Unit = {}, onJoinCall: (ConversationId) -> Unit = {}, onConversationSelectedOnRadioGroup: (ConversationItem) -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit = {} + onAudioPermissionPermanentlyDenied: () -> Unit = {}, + onPlayPauseCurrentAudio: () -> Unit = { }, + onStopCurrentAudio: () -> Unit = {} ) { val context = LocalContext.current @@ -123,6 +125,8 @@ fun ConversationList( openUserProfile = onOpenUserProfile, joinCall = onJoinCall, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) else -> {} @@ -149,7 +153,9 @@ fun ConversationList( onOpenUserProfile: (UserId) -> Unit = {}, onJoinCall: (ConversationId) -> Unit = {}, onConversationSelectedOnRadioGroup: (ConversationId) -> Unit = {}, - onAudioPermissionPermanentlyDenied: () -> Unit = {} + onAudioPermissionPermanentlyDenied: () -> Unit = {}, + onPlayPauseCurrentAudio: () -> Unit = { }, + onStopCurrentAudio: () -> Unit = {} ) { val context = LocalContext.current @@ -178,6 +184,8 @@ fun ConversationList( openUserProfile = onOpenUserProfile, joinCall = onJoinCall, onAudioPermissionPermanentlyDenied = onAudioPermissionPermanentlyDenied, + onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, + onStopCurrentAudio = onStopCurrentAudio ) } } @@ -208,7 +216,8 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, searchQuery = searchQuery, isFavorite = false, - folder = null + folder = null, + playingAudio = null ) ) @@ -229,7 +238,8 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f searchQuery = searchQuery, isFavorite = false, isUserDeleted = false, - folder = null + folder = null, + playingAudio = null ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index 3928dcb63f..5db257b995 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -47,6 +47,7 @@ sealed class ConversationItem : ConversationFolderItem { abstract val proteusVerificationStatus: Conversation.VerificationStatus abstract val hasNewActivitiesToShow: Boolean abstract val searchQuery: String + abstract val playingAudio: PlayingAudioInConversation? val isTeamConversation get() = teamId != null @@ -70,6 +71,7 @@ sealed class ConversationItem : ConversationFolderItem { override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", + override val playingAudio: PlayingAudioInConversation? ) : ConversationItem() @Serializable @@ -92,6 +94,7 @@ sealed class ConversationItem : ConversationFolderItem { override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", + override val playingAudio: PlayingAudioInConversation? ) : ConversationItem() @Serializable @@ -112,6 +115,7 @@ sealed class ConversationItem : ConversationFolderItem { override val teamId: TeamId? = null override val mlsVerificationStatus: Conversation.VerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED override val proteusVerificationStatus: Conversation.VerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + override val playingAudio: PlayingAudioInConversation? = null } } @@ -122,6 +126,12 @@ data class ConversationInfo( val isSenderUnavailable: Boolean = false ) +@Serializable +data class PlayingAudioInConversation( + val messageId: String, + val isPaused: Boolean +) + enum class BlockingState { CAN_NOT_BE_BLOCKED, // we should not be able to block our own team-members BLOCKED, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt index e6fc0b029f..590eab6480 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt @@ -83,7 +83,7 @@ class RecordAudioViewModel @Inject constructor( fun setApplyEffectsAndPlayAudio(enabled: Boolean) { setShouldApplyEffects(enabled = enabled) - if (state.audioState.audioMediaPlayingState is AudioMediaPlayingState.Playing) { + if (state.audioState.isPlaying()) { onPlayAudio() } } diff --git a/app/src/main/res/layout/notification_playing_audio_message.xml b/app/src/main/res/layout/notification_playing_audio_message.xml new file mode 100644 index 0000000000..f8d9548418 --- /dev/null +++ b/app/src/main/res/layout/notification_playing_audio_message.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36b7c4674a..fb37d05425 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -971,6 +971,7 @@ Someone Incoming group call Tap to return to call - Calling... + Audio message Constant Bit Rate Microphone diff --git a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt index 7092e0b5b7..390b5a26f1 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt @@ -47,7 +47,8 @@ object TestConversationItem { proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, isUserDeleted = false, - folder = null + folder = null, + playingAudio = null ) val GROUP = ConversationItem.GroupConversation( @@ -65,7 +66,8 @@ object TestConversationItem { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isFavorite = false, - folder = null + folder = null, + playingAudio = null ) val CONNECTION = ConversationItem.ConnectionConversation( diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index 4f6dc7d8b7..3274b097aa 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -23,11 +23,14 @@ 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.AudioFocusHelper 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.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper +import com.wire.android.services.ServicesManager import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.id.ConversationId @@ -40,6 +43,9 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path import org.amshove.kluent.internal.assertEquals @@ -57,37 +63,40 @@ class ConversationAudioMessagePlayerTest { .arrange() val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -103,6 +112,9 @@ class ConversationAudioMessagePlayerTest { verify { mediaPlayer.setDataSource(any(), any()) } verify { mediaPlayer.start() } + verify(exactly = 1) { audioFocusHelper.request() } + verify(exactly = 1) { servicesManager.startPlayingAudioMessageService() } + verify(exactly = 0) { mediaPlayer.seekTo(any()) } } } @@ -117,38 +129,42 @@ class ConversationAudioMessagePlayerTest { .arrange() val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } + awaitItem() // currentPosition update awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -158,11 +174,12 @@ class ConversationAudioMessagePlayerTest { // playing second time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) + advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Paused) } @@ -191,38 +208,42 @@ class ConversationAudioMessagePlayerTest { val firstAudioMessageId = "some-dummy-message-id1" val secondAudioMessageId = "some-dummy-message-id2" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val firstAudioMessageIdWrapper = MessageIdWrapper(conversationId, firstAudioMessageId) + val secondAudioMessageIdWrapper = MessageIdWrapper(conversationId, secondAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first audio message conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, firstAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs assert(totalTime is AudioState.TotalTimeInMs.Known) @@ -231,33 +252,39 @@ class ConversationAudioMessagePlayerTest { // playing second audio message conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, secondAudioMessageId ) awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Stopped) } awaitAndAssertStateUpdate { state -> - assert(state.size == 2) + val currentState = state[firstAudioMessageIdWrapper] + assert(currentState != null) + assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Completed) + } + awaitItem() // seekToAudioPosition + awaitAndAssertStateUpdate { state -> + assertEquals(2, state.size) - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } @@ -285,38 +312,42 @@ class ConversationAudioMessagePlayerTest { val firstAudioMessageId = "some-dummy-message-id1" val secondAudioMessageId = "some-dummy-message-id2" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val firstAudioMessageIdWrapper = MessageIdWrapper(conversationId, firstAudioMessageId) + val secondAudioMessageIdWrapper = MessageIdWrapper(conversationId, secondAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first audio message conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, firstAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -329,37 +360,44 @@ class ConversationAudioMessagePlayerTest { ConversationId("some-dummy-value", "some.dummy.domain"), secondAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Stopped) } awaitAndAssertStateUpdate { state -> - assert(state.size == 2) + val currentState = state[firstAudioMessageIdWrapper] + assert(currentState != null) + assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Completed) + } + awaitItem() // seekToAudioPosition + awaitAndAssertStateUpdate { state -> + assertEquals(2, state.size) - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs assert(totalTime is AudioState.TotalTimeInMs.Known) @@ -371,33 +409,40 @@ class ConversationAudioMessagePlayerTest { ConversationId("some-dummy-value", "some.dummy.domain"), firstAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[secondAudioMessageId] + val currentState = state[secondAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Stopped) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] + assert(currentState != null) + assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Completed) + } + awaitItem() // seekToAudioPosition + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } awaitAndAssertStateUpdate { state -> - val currentState = state[firstAudioMessageId] + val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs @@ -427,38 +472,42 @@ class ConversationAudioMessagePlayerTest { .arrange() val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() // playing first time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Fetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) } awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } + awaitItem() // currentPosition update awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) val totalTime = currentState!!.totalTimeInMs assert(totalTime is AudioState.TotalTimeInMs.Known) @@ -467,11 +516,12 @@ class ConversationAudioMessagePlayerTest { // playing second time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Paused) } @@ -481,11 +531,12 @@ class ConversationAudioMessagePlayerTest { // playing third time conversationAudioMessagePlayer.playAudio( - ConversationId("some-dummy-value", "some.dummy.domain"), + conversationId, testAudioMessageId ) + this@runTest.advanceUntilIdle() awaitAndAssertStateUpdate { state -> - val currentState = state[testAudioMessageId] + val currentState = state[messageIdWrapper] assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.Playing) } @@ -519,6 +570,37 @@ class ConversationAudioMessagePlayerTest { verify(exactly = 1) { arrangement.mediaPlayer.playbackParams = params.setSpeed(2F) } } + @Test + fun givenPlayingAudioMessage_whenStopAudioCalled_thenServiceStoppedAndAudioFocusAbandoned() = runTest { + val (arrangement, conversationAudioMessagePlayer) = Arrangement() + .withAudioMediaPlayerReturningTotalTime(1000) + .withSuccessFullAssetFetch() + .withCurrentSession() + .arrange() + + val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + + conversationAudioMessagePlayer.observableAudioMessagesState.test { + // skip first emit from onStart + awaitItem() + conversationAudioMessagePlayer.playAudio( + conversationId, + testAudioMessageId + ) + this@runTest.advanceUntilIdle() + + conversationAudioMessagePlayer.forceToStopCurrentAudioMessage() + + cancelAndIgnoreRemainingEvents() + } + + with(arrangement) { + verify(exactly = 1) { audioFocusHelper.abandon() } + verify(exactly = 1) { servicesManager.stopPlayingAudioMessageService() } + } + } + private suspend fun TurbineTestContext.awaitAndAssertStateUpdate(assertion: (T) -> Unit) { val state = awaitItem() assert(state != null) @@ -541,12 +623,23 @@ class Arrangement { @MockK lateinit var wavesMaskHelper: AudioWavesMaskHelper + @MockK + lateinit var servicesManager: ServicesManager + + @MockK + lateinit var audioFocusHelper: AudioFocusHelper + + private val testScope = CoroutineScope(UnconfinedTestDispatcher()) + private val conversationAudioMessagePlayer by lazy { ConversationAudioMessagePlayer( context, mediaPlayer, wavesMaskHelper, + { servicesManager }, + audioFocusHelper, coreLogic, + testScope ) } @@ -555,6 +648,14 @@ class Arrangement { every { wavesMaskHelper.getWaveMask(any()) } returns WAVES_MASK every { wavesMaskHelper.clear() } returns Unit + every { mediaPlayer.currentPosition } returns 100 + + every { servicesManager.stopPlayingAudioMessageService() } returns Unit + every { servicesManager.startPlayingAudioMessageService() } returns Unit + + every { audioFocusHelper.setListener(any(), any()) } returns Unit + every { audioFocusHelper.abandon() } returns Unit + every { audioFocusHelper.request() } returns true } 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 01f5cb4d25..da02a0e0a1 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -25,7 +25,8 @@ 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 +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage @@ -47,7 +48,6 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase -import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -99,9 +99,6 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var conversationAudioMessagePlayer: ConversationAudioMessagePlayer - @MockK - lateinit var conversationAudioMessagePlayerProvider: ConversationAudioMessagePlayerProvider - @MockK lateinit var getConversationUnreadEventsCount: GetConversationUnreadEventsCountUseCase @@ -117,9 +114,6 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var deleteMessage: DeleteMessageUseCase - @MockK - lateinit var getSenderNameByMessageId: GetSenderNameByMessageIdUseCase - private val viewModel: ConversationMessagesViewModel by lazy { ConversationMessagesViewModel( savedStateHandle, @@ -133,12 +127,11 @@ class ConversationMessagesViewModelArrangement { getMessagesForConversationUseCase, toggleReaction, resetSession, - conversationAudioMessagePlayerProvider, + conversationAudioMessagePlayer, getConversationUnreadEventsCount, clearUsersTypingEvents, getSearchedConversationMessagePosition, deleteMessage, - getSenderNameByMessageId ) } @@ -153,8 +146,6 @@ class ConversationMessagesViewModelArrangement { coEvery { getConversationUnreadEventsCount(any()) } returns GetConversationUnreadEventsCountUseCase.Result.Success(0L) coEvery { updateAssetMessageDownloadStatus(any(), any(), any()) } returns UpdateTransferStatusResult.Success coEvery { clearUsersTypingEvents() } returns Unit - every { conversationAudioMessagePlayerProvider.provide() } returns conversationAudioMessagePlayer - every { conversationAudioMessagePlayerProvider.onCleared() } returns Unit coEvery { getSearchedConversationMessagePosition(any(), any()) } returns GetSearchedConversationMessagePositionUseCase.Result.Success(position = 0) @@ -163,7 +154,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") + coEvery { conversationAudioMessagePlayer.playingAudioMessageFlow } returns flowOf(PlayingAudioMessage.None) } fun withSuccessfulViewModelInit() = apply { @@ -203,10 +194,14 @@ class ConversationMessagesViewModelArrangement { ) } - fun withObservableAudioMessagesState(audioFlow: Flow>) = apply { + fun withObservableAudioMessagesState(audioFlow: Flow>) = apply { coEvery { conversationAudioMessagePlayer.observableAudioMessagesState } returns audioFlow } + fun withPlayingAudioMessageFlow(playingAudioMessageFlow: Flow) = apply { + coEvery { conversationAudioMessagePlayer.playingAudioMessageFlow } returns playingAudioMessageFlow + } + suspend fun withPaginatedMessagesReturning(pagingDataFlow: PagingData) = apply { messagesChannel.send(pagingDataFlow) } @@ -237,9 +232,5 @@ class ConversationMessagesViewModelArrangement { return this } - fun withGetSenderNameByMessageId(result: GetSenderNameByMessageIdUseCase.Result) = apply { - coEvery { getSenderNameByMessageId(any(), any()) } returns result - } - fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index 18f19efd89..1df6eecd8e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -29,17 +29,18 @@ import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.composer.mockUIAudioMessage import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState -import com.wire.kalium.logic.CoreFailure +import com.wire.android.util.ui.UIText 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 @@ -331,67 +332,32 @@ class ConversationMessagesViewModelTest { } @Test - fun `given an message ID, when some Audio is played, then should get message sender name by message ID`() = runTest { + fun `given an message ID, when some Audio is played, then state contains it`() = runTest { val message = TestMessage.ASSET_MESSAGE val audioState = AudioState.DEFAULT.copy( audioMediaPlayingState = AudioMediaPlayingState.Playing, totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), currentPositionInMs = 300 ) - val userName = "some name" - val expectedAudioMessagesState = AudioMessagesState( - audioStates = persistentMapOf(message.id to audioState), - audioSpeed = AudioSpeed.NORMAL, - playingAudiMessage = PlayingAudiMessage( - messageId = message.id, - authorName = userName, - currentTimeMs = audioState.currentPositionInMs - ) - ) - val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withSuccessfulViewModelInit() - .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Success(userName)) - .withObservableAudioMessagesState( - flowOf( - mapOf( - message.id to audioState.copy(currentPositionInMs = 100), - message.id to audioState - ) - ) - ) - .arrange() - - advanceUntilIdle() - - coVerify(exactly = 1) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } - assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) - } - - @Test - fun `given an message ID, when getSenderNameByMessageId fails, then senderName in PlayingAudiMessage is empty`() = runTest { - val message = TestMessage.ASSET_MESSAGE - val audioState = AudioState.DEFAULT.copy( - audioMediaPlayingState = AudioMediaPlayingState.Playing, - totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), - currentPositionInMs = 300 + val playingAudiMessage = PlayingAudioMessage.Some( + conversationId = message.conversationId, + messageId = message.id, + authorName = UIText.DynamicString("some name"), + state = AudioState.DEFAULT.copy(currentPositionInMs = audioState.currentPositionInMs) ) val expectedAudioMessagesState = AudioMessagesState( audioStates = persistentMapOf(message.id to audioState), audioSpeed = AudioSpeed.NORMAL, - playingAudiMessage = PlayingAudiMessage( - messageId = message.id, - authorName = "", - currentTimeMs = audioState.currentPositionInMs - ) + playingAudiMessage = playingAudiMessage ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() .withSuccessfulViewModelInit() - .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Failure(CoreFailure.Unknown(null))) + .withPlayingAudioMessageFlow(flowOf(playingAudiMessage)) .withObservableAudioMessagesState( flowOf( mapOf( - message.id to audioState.copy(currentPositionInMs = 100), - message.id to audioState + MessageIdWrapper(message.conversationId, message.id) to audioState.copy(currentPositionInMs = 100), + MessageIdWrapper(message.conversationId, message.id) to audioState ) ) ) @@ -403,7 +369,7 @@ class ConversationMessagesViewModelTest { } @Test - fun `given an message ID, when no playing Audio message, then PlayingAudiMessage is null`() = runTest { + fun `given an message ID, when no playing Audio message, then PlayingAudioMessage is None`() = runTest { val message = TestMessage.ASSET_MESSAGE val audioState = AudioState.DEFAULT.copy( audioMediaPlayingState = AudioMediaPlayingState.Stopped, @@ -413,16 +379,16 @@ class ConversationMessagesViewModelTest { val expectedAudioMessagesState = AudioMessagesState( audioStates = persistentMapOf(message.id to audioState), audioSpeed = AudioSpeed.NORMAL, - playingAudiMessage = null + playingAudiMessage = PlayingAudioMessage.None ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() .withSuccessfulViewModelInit() - .withObservableAudioMessagesState(flowOf(mapOf(message.id to audioState))) + .withPlayingAudioMessageFlow(flowOf(PlayingAudioMessage.None)) + .withObservableAudioMessagesState(flowOf(mapOf(MessageIdWrapper(message.conversationId, message.id) to audioState))) .arrange() advanceUntilIdle() - coVerify(exactly = 0) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index c4c24b1e41..ac3bb69407 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -32,6 +32,8 @@ import com.wire.android.framework.TestConversationDetails import com.wire.android.framework.TestConversationItem import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.common.dialogs.BlockUserDialogState import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase import com.wire.android.ui.home.conversationslist.model.ConversationItem @@ -60,6 +62,7 @@ import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -82,58 +85,58 @@ class ConversationListViewModelTest { @Test fun `given initial empty search query, when collecting conversations, then call use case with proper params`() = runTest(dispatcherProvider.main()) { - // Given - val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + // Given + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() - // When - (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { - // Then - coVerify(exactly = 1) { - arrangement.getConversationsPaginated("", false, true, false) + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated("", false, true, false) + } + cancelAndIgnoreRemainingEvents() } - cancelAndIgnoreRemainingEvents() } - } @Test fun `given updated non-empty search query, when collecting conversations, then call use case with proper params`() = runTest(dispatcherProvider.main()) { - // Given - val searchQueryText = "search" - val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() - // When - (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { - conversationListViewModel.searchQueryChanged(searchQueryText) - advanceUntilIdle() + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() - // Then - coVerify(exactly = 1) { - arrangement.getConversationsPaginated(searchQueryText, false, true, false) + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, false, true, false) + } + cancelAndIgnoreRemainingEvents() } - cancelAndIgnoreRemainingEvents() } - } @Test fun `given updated non-empty search query, when collecting archived, then call use case with proper params`() = runTest(dispatcherProvider.main()) { - // Given - val searchQueryText = "search" - val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() - // When - (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { - conversationListViewModel.searchQueryChanged(searchQueryText) - advanceUntilIdle() + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() - // Then - coVerify(exactly = 1) { - arrangement.getConversationsPaginated(searchQueryText, true, false, false) + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, true, false, false) + } + cancelAndIgnoreRemainingEvents() } - cancelAndIgnoreRemainingEvents() } - } @Test fun `given self user is under legal hold, when collecting conversations, then hide LH indicators`() = @@ -193,49 +196,49 @@ class ConversationListViewModelTest { @Test fun `given a valid conversation muting state, when calling muteConversation, then should call with call the UseCase`() = runTest(dispatcherProvider.main()) { - // Given - val (arrangement, conversationListViewModel) = Arrangement() - .updateConversationMutedStatusSuccess() - .arrange() + // Given + val (arrangement, conversationListViewModel) = Arrangement() + .updateConversationMutedStatusSuccess() + .arrange() - // When - conversationListViewModel.muteConversation(conversationId, MutedConversationStatus.AllMuted) + // When + conversationListViewModel.muteConversation(conversationId, MutedConversationStatus.AllMuted) - // Then - coVerify(exactly = 1) { - arrangement.updateConversationMutedStatus(conversationId, MutedConversationStatus.AllMuted, any()) + // Then + coVerify(exactly = 1) { + arrangement.updateConversationMutedStatus(conversationId, MutedConversationStatus.AllMuted, any()) + } } - } @Test fun `given a valid conversation muting state, when calling block user, then should call BlockUserUseCase`() = runTest(dispatcherProvider.main()) { - // Given - val (arrangement, conversationListViewModel) = Arrangement() - .blockUserSuccess() - .arrange() + // Given + val (arrangement, conversationListViewModel) = Arrangement() + .blockUserSuccess() + .arrange() - // When - conversationListViewModel.blockUser(BlockUserDialogState(userName = "someName", userId = userId)) + // When + conversationListViewModel.blockUser(BlockUserDialogState(userName = "someName", userId = userId)) - // Then - coVerify(exactly = 1) { arrangement.blockUser(userId) } - } + // Then + coVerify(exactly = 1) { arrangement.blockUser(userId) } + } @Test fun `given a valid conversation muting state, when calling unblock user, then should call BlockUserUseCase`() = runTest(dispatcherProvider.main()) { - // Given - val (arrangement, conversationListViewModel) = Arrangement() - .unblockUserSuccess() - .arrange() + // Given + val (arrangement, conversationListViewModel) = Arrangement() + .unblockUserSuccess() + .arrange() - // When - conversationListViewModel.unblockUser(userId) + // When + conversationListViewModel.unblockUser(userId) - // Then - coVerify(exactly = 1) { arrangement.unblockUser(userId) } - } + // Then + coVerify(exactly = 1) { arrangement.unblockUser(userId) } + } @Test fun `given cached PagingData, when self user legal hold changes, then should call paginated use case again`() = @@ -352,6 +355,9 @@ class ConversationListViewModelTest { @MockK private lateinit var workManager: WorkManager + @MockK + lateinit var audioMessagePlayer: ConversationAudioMessagePlayer + init { MockKAnnotations.init(this, relaxUnitFun = true) withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) @@ -367,6 +373,7 @@ class ConversationListViewModelTest { ) } ) + every { audioMessagePlayer.playingAudioMessageFlow } returns flowOf(PlayingAudioMessage.None) mockUri() } @@ -426,6 +433,7 @@ class ConversationListViewModelTest { userTypeMapper = UserTypeMapper(), getSelfUser = getSelfUser, usePagination = true, + audioMessagePlayer = audioMessagePlayer, workManager = workManager ) } 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 fdf99a843c..fde20418a8 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 @@ -143,6 +143,7 @@ data class WireDimensions( val spacing8x: Dp, val spacing10x: Dp, val spacing12x: Dp, + val spacing14x: Dp, val spacing16x: Dp, val spacing18x: Dp, val spacing20x: Dp, @@ -306,6 +307,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( spacing8x = 8.dp, spacing10x = 10.dp, spacing12x = 12.dp, + spacing14x = 14.dp, spacing16x = 16.dp, spacing18x = 18.dp, spacing20x = 20.dp,