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