forked from dsutanto/bChot-android
First Commit
This commit is contained in:
20
libraries/voiceplayer/api/build.gradle.kts
Normal file
20
libraries/voiceplayer/api/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.voiceplayer.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotationjvm)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.api
|
||||
|
||||
sealed interface VoiceMessageEvents {
|
||||
data object PlayPause : VoiceMessageEvents
|
||||
data class Seek(val percentage: Float) : VoiceMessageEvents
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.api
|
||||
|
||||
sealed class VoiceMessageException : Exception() {
|
||||
data class FileException(
|
||||
override val message: String?,
|
||||
override val cause: Throwable? = null
|
||||
) : VoiceMessageException()
|
||||
|
||||
data class PermissionMissing(
|
||||
override val message: String?,
|
||||
override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
|
||||
data class PlayMessageError(
|
||||
override val message: String?,
|
||||
override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.api
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface VoiceMessagePresenterFactory {
|
||||
fun createVoiceMessagePresenter(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
duration: Duration,
|
||||
): Presenter<VoiceMessageState>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.api
|
||||
|
||||
data class VoiceMessageState(
|
||||
val button: Button,
|
||||
val progress: Float,
|
||||
val time: String,
|
||||
val showCursor: Boolean,
|
||||
val eventSink: (event: VoiceMessageEvents) -> Unit,
|
||||
) {
|
||||
enum class Button {
|
||||
Play,
|
||||
Pause,
|
||||
Downloading,
|
||||
Retry,
|
||||
Disabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.api
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
|
||||
override val values: Sequence<VoiceMessageState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Downloading,
|
||||
progress = 0f,
|
||||
time = "0:00",
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Retry,
|
||||
progress = 0.5f,
|
||||
time = "0:01",
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Play,
|
||||
progress = 1f,
|
||||
time = "1:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Pause,
|
||||
progress = 0.2f,
|
||||
time = "10:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Disabled,
|
||||
progress = 0.2f,
|
||||
time = "30:00",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aVoiceMessageState(
|
||||
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
|
||||
progress: Float = 0f,
|
||||
time: String = "1:00",
|
||||
showCursor: Boolean = false,
|
||||
) = VoiceMessageState(
|
||||
button = button,
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
eventSink = {},
|
||||
)
|
||||
40
libraries/voiceplayer/impl/build.gradle.kts
Normal file
40
libraries/voiceplayer/impl/build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.voiceplayer.impl"
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.voiceplayer.api)
|
||||
|
||||
implementation(projects.libraries.audio.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.mediaplayer.api)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.services.analytics.api)
|
||||
|
||||
implementation(libs.androidx.annotationjvm)
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(libs.coroutines.core)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediaplayer.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlin.time.Duration
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultVoiceMessagePresenterFactory(
|
||||
private val analyticsService: AnalyticsService,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
||||
) : VoiceMessagePresenterFactory {
|
||||
override fun createVoiceMessagePresenter(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
duration: Duration,
|
||||
): Presenter<VoiceMessageState> {
|
||||
val player = voiceMessagePlayerFactory.create(
|
||||
eventId = eventId,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
)
|
||||
|
||||
return VoiceMessagePresenter(
|
||||
analyticsService = analyticsService,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
player = player,
|
||||
eventId = eventId,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.mxc.MxcTools
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fetches the media file for a voice message.
|
||||
*
|
||||
* Media is downloaded from the rust sdk and stored in the application's cache directory.
|
||||
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
|
||||
* Whenever a given mxc is found in the cache, it is returned immediately.
|
||||
*/
|
||||
interface VoiceMessageMediaRepo {
|
||||
/**
|
||||
* Factory for [VoiceMessageMediaRepo].
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessageMediaRepo].
|
||||
*
|
||||
* @param mediaSource the media source of the voice message.
|
||||
* @param mimeType the mime type of the voice message.
|
||||
* @param filename the filename of the voice message.
|
||||
*/
|
||||
fun create(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): VoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the voice message media file.
|
||||
*
|
||||
* In case of a cache hit the file is returned immediately.
|
||||
* In case of a cache miss the file is downloaded and then returned.
|
||||
*
|
||||
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
|
||||
*/
|
||||
suspend fun getMediaFile(): Result<File>
|
||||
}
|
||||
|
||||
@AssistedInject
|
||||
class DefaultVoiceMessageMediaRepo(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
mxcTools: MxcTools,
|
||||
private val matrixMediaLoader: MatrixMediaLoader,
|
||||
@Assisted private val mediaSource: MediaSource,
|
||||
@Assisted("mimeType") private val mimeType: String?,
|
||||
@Assisted("filename") private val filename: String?,
|
||||
) : VoiceMessageMediaRepo {
|
||||
@ContributesBinding(RoomScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : VoiceMessageMediaRepo.Factory {
|
||||
override fun create(
|
||||
mediaSource: MediaSource,
|
||||
@Assisted("mimeType") mimeType: String?,
|
||||
@Assisted("filename") filename: String?,
|
||||
): DefaultVoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
override suspend fun getMediaFile(): Result<File> = when {
|
||||
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
|
||||
cachedFile.exists() -> Result.success(cachedFile)
|
||||
else -> matrixMediaLoader.downloadMediaFile(
|
||||
source = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
).mapCatchingExceptions {
|
||||
it.use { mediaFile ->
|
||||
val dest = cachedFile.apply { parentFile?.mkdirs() }
|
||||
if (mediaFile.persist(dest.path)) {
|
||||
dest
|
||||
} else {
|
||||
error("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.url)?.let {
|
||||
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectory of the application's cache directory where voice messages are stored.
|
||||
*/
|
||||
private const val CACHE_VOICE_SUBDIR = "temp/voice"
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A media player specialized in playing a single voice message.
|
||||
*/
|
||||
interface VoiceMessagePlayer {
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessagePlayer].
|
||||
*
|
||||
* NB: Different voice messages can use the same content uri (e.g. in case of
|
||||
* a forward of a voice message),
|
||||
* therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
|
||||
* a voice message. This is why we must provide the eventId as well.
|
||||
*
|
||||
* @param eventId The eventId of the voice message event.
|
||||
* @param mediaSource The media source of the voice message.
|
||||
* @param mimeType The mime type of the voice message.
|
||||
* @param filename The filename of the voice message.
|
||||
*/
|
||||
fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): VoiceMessagePlayer
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of this player.
|
||||
*/
|
||||
val state: Flow<State>
|
||||
|
||||
/**
|
||||
* Acquires control of the underlying [MediaPlayer] and prepares it
|
||||
* to play the media file.
|
||||
*
|
||||
* Will suspend whilst the media file is being downloaded and/or
|
||||
* the underlying [MediaPlayer] is loading the media file.
|
||||
*/
|
||||
suspend fun prepare(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Play the media.
|
||||
*/
|
||||
fun play()
|
||||
|
||||
/**
|
||||
* Pause playback.
|
||||
*/
|
||||
fun pause()
|
||||
|
||||
/**
|
||||
* Seek to a specific position.
|
||||
*
|
||||
* @param positionMs The position in milliseconds.
|
||||
*/
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
data class State(
|
||||
/**
|
||||
* Whether the player is ready to play.
|
||||
*/
|
||||
val isReady: Boolean,
|
||||
/**
|
||||
* Whether this player is currently playing.
|
||||
*/
|
||||
val isPlaying: Boolean,
|
||||
/**
|
||||
* Whether the player has reached the end of the media.
|
||||
*/
|
||||
val isEnded: Boolean,
|
||||
/**
|
||||
* The elapsed time of this player in milliseconds.
|
||||
*/
|
||||
val currentPosition: Long,
|
||||
/**
|
||||
* The duration of the current content, if available.
|
||||
*/
|
||||
val duration: Long?,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of [VoiceMessagePlayer] which is backed by a
|
||||
* [VoiceMessageMediaRepo] to fetch and cache the media file and
|
||||
* which uses a global [MediaPlayer] instance to play the media.
|
||||
*/
|
||||
class DefaultVoiceMessagePlayer(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
private val eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
) : VoiceMessagePlayer {
|
||||
@ContributesBinding(RoomScope::class) // Scoped types can't use @Inject.
|
||||
class Factory(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
) : VoiceMessagePlayer.Factory {
|
||||
override fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
|
||||
eventId = eventId,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
)
|
||||
}
|
||||
|
||||
private val repo = voiceMessageMediaRepoFactory.create(
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
)
|
||||
|
||||
private var internalState = MutableStateFlow(
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
currentPosition = 0L,
|
||||
duration = null
|
||||
)
|
||||
)
|
||||
|
||||
override val state: Flow<VoiceMessagePlayer.State> = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState ->
|
||||
if (mediaPlayerState.isMyTrack) {
|
||||
this.internalState.update {
|
||||
it.copy(
|
||||
isReady = mediaPlayerState.isReady,
|
||||
isPlaying = mediaPlayerState.isPlaying,
|
||||
isEnded = mediaPlayerState.isEnded,
|
||||
currentPosition = mediaPlayerState.currentPosition,
|
||||
duration = mediaPlayerState.duration,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.internalState.update {
|
||||
it.copy(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = internalState.isReady,
|
||||
isPlaying = internalState.isPlaying,
|
||||
isEnded = internalState.isEnded,
|
||||
currentPosition = internalState.currentPosition,
|
||||
duration = internalState.duration,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override suspend fun prepare(): Result<Unit> = if (eventId != null) {
|
||||
repo.getMediaFile().mapCatchingExceptions<Unit, File> { mediaFile ->
|
||||
val state = internalState.value
|
||||
mediaPlayer.setMedia(
|
||||
uri = mediaFile.path,
|
||||
mediaId = eventId.value,
|
||||
// Files in the voice cache have no extension so we need to set the mime type manually.
|
||||
mimeType = MimeTypes.Ogg,
|
||||
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
if (inControl()) {
|
||||
mediaPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (inControl()) {
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
if (inControl()) {
|
||||
mediaPlayer.seekTo(positionMs)
|
||||
} else {
|
||||
internalState.update {
|
||||
it.copy(currentPosition = positionMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MediaPlayer.State.isMyTrack: Boolean
|
||||
get() = if (eventId == null) false else this.mediaId == eventId.value
|
||||
|
||||
private fun inControl(): Boolean = mediaPlayer.state.value.let {
|
||||
it.isMyTrack && (it.isReady || it.isEnded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class VoiceMessagePresenter(
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val player: VoiceMessagePlayer,
|
||||
private val eventId: EventId?,
|
||||
private val duration: Duration,
|
||||
) : Presenter<VoiceMessageState> {
|
||||
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
|
||||
|
||||
@Composable
|
||||
override fun present(): VoiceMessageState {
|
||||
val playerState by player.state.collectAsState(
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
currentPosition = 0L,
|
||||
duration = null
|
||||
)
|
||||
)
|
||||
|
||||
val button by remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
eventId == null -> VoiceMessageState.Button.Disabled
|
||||
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
||||
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
|
||||
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
|
||||
else -> VoiceMessageState.Button.Play
|
||||
}
|
||||
}
|
||||
}
|
||||
val duration by remember {
|
||||
derivedStateOf { playerState.duration ?: duration.inWholeMilliseconds }
|
||||
}
|
||||
val progress by remember {
|
||||
derivedStateOf {
|
||||
playerState.currentPosition / duration.toFloat()
|
||||
}
|
||||
}
|
||||
val time by remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
|
||||
playerState.currentPosition > 0 -> playerState.currentPosition
|
||||
else -> duration
|
||||
}.milliseconds.formatShort()
|
||||
}
|
||||
}
|
||||
val showCursor by remember {
|
||||
derivedStateOf {
|
||||
!play.value.isUninitialized() && !playerState.isEnded
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: VoiceMessageEvents) {
|
||||
when (event) {
|
||||
is VoiceMessageEvents.PlayPause -> {
|
||||
if (playerState.isPlaying) {
|
||||
player.pause()
|
||||
} else if (playerState.isReady) {
|
||||
player.play()
|
||||
} else {
|
||||
sessionCoroutineScope.launch {
|
||||
play.runUpdatingState(
|
||||
errorTransform = {
|
||||
analyticsService.trackError(
|
||||
VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
|
||||
)
|
||||
it
|
||||
},
|
||||
) {
|
||||
player.prepare().flatMap {
|
||||
runCatchingExceptions { player.play() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is VoiceMessageEvents.Seek -> {
|
||||
player.seekTo((event.percentage * duration).toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VoiceMessageState(
|
||||
button = button,
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.test.mxc.FakeMxcTools
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
class DefaultVoiceMessageMediaRepoTest {
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `cache miss - downloads and returns cached file successfully`() = runTest {
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
path = temporaryFolder.createRustMediaFile().path
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
result.getOrThrow().let { file ->
|
||||
assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
|
||||
assertThat(file.exists()).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache miss - download fails`() = runTest {
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
shouldFail = true
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isFailure).isTrue()
|
||||
result.exceptionOrNull()!!.let { exception ->
|
||||
assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache miss - download succeeds but file move fails`() = runTest {
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
path = temporaryFolder.createRustMediaFile().path
|
||||
}
|
||||
File(temporaryFolder.cachedFilePath).apply {
|
||||
parentFile?.mkdirs()
|
||||
// Deny access to parent folder so move to cache will fail.
|
||||
parentFile?.setReadable(false)
|
||||
parentFile?.setWritable(false)
|
||||
parentFile?.setExecutable(false)
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isFailure).isTrue()
|
||||
result.exceptionOrNull()?.let { exception ->
|
||||
assertThat(exception).apply {
|
||||
isInstanceOf(IllegalStateException::class.java)
|
||||
hasMessageThat().isEqualTo("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache hit - returns cached file successfully`() = runTest {
|
||||
temporaryFolder.createCachedFile()
|
||||
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
|
||||
shouldFail = true // so that if we hit the media loader it will crash
|
||||
}
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
)
|
||||
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
result.getOrThrow().let { file ->
|
||||
assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
|
||||
assertThat(file.exists()).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid mxc uri returns a failure`() = runTest {
|
||||
val repo = createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder = temporaryFolder,
|
||||
mxcUri = INVALID_MXC_URI,
|
||||
)
|
||||
repo.getMediaFile().let { result ->
|
||||
assertThat(result.isFailure).isTrue()
|
||||
result.exceptionOrNull()!!.let { exception ->
|
||||
assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
||||
assertThat(exception).hasMessageThat().isEqualTo("Invalid mxcUri.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDefaultVoiceMessageMediaRepo(
|
||||
temporaryFolder: TemporaryFolder,
|
||||
matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
|
||||
mxcUri: String = MXC_URI,
|
||||
) = DefaultVoiceMessageMediaRepo(
|
||||
cacheDir = temporaryFolder.root,
|
||||
mxcTools = FakeMxcTools(),
|
||||
matrixMediaLoader = matrixMediaLoader,
|
||||
mediaSource = MediaSource(
|
||||
url = mxcUri,
|
||||
json = null
|
||||
),
|
||||
mimeType = MimeTypes.Ogg,
|
||||
filename = "someBody.ogg"
|
||||
)
|
||||
|
||||
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
||||
private const val INVALID_MXC_URI = "notAnMxcUri"
|
||||
private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg"
|
||||
private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
|
||||
parentFile?.mkdirs()
|
||||
createNewFile()
|
||||
}
|
||||
|
||||
private fun TemporaryFolder.createRustMediaFile() = File(this.root, "rustMediaFile.ogg").apply { createNewFile() }
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultVoiceMessagePlayerTest {
|
||||
@Test
|
||||
fun `initial state`() = runTest {
|
||||
createDefaultVoiceMessagePlayer().state.test {
|
||||
matchInitialState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare succeeds`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare fails when repo fails`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
|
||||
shouldFail = true
|
||||
},
|
||||
)
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isFailure).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare fails with no eventId`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
eventId = null
|
||||
)
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isFailure).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play after prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.play()
|
||||
awaitItem().let {
|
||||
assertThat(it.isPlaying).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play reaches end of media`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer(
|
||||
mediaPlayer = FakeMediaPlayer(
|
||||
fakeTotalDurationMs = 1000,
|
||||
fakePlayedDurationMs = 1000
|
||||
)
|
||||
)
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState(fakeTotalDurationMs = 1000)
|
||||
player.play()
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `player1 plays again after both player1 and player2 are finished`() = runTest {
|
||||
val mediaPlayer = FakeMediaPlayer(
|
||||
fakeTotalDurationMs = 1_000L,
|
||||
fakePlayedDurationMs = 1_000L,
|
||||
)
|
||||
val player1 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
|
||||
val player2 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
|
||||
|
||||
// Play player1 until the end.
|
||||
player1.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player1.prepare().isSuccess).isTrue()
|
||||
matchReadyState(1_000L)
|
||||
player1.play()
|
||||
awaitItem().let {
|
||||
// it plays until the end.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Play player2 until the end.
|
||||
player2.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player2.prepare().isSuccess).isTrue()
|
||||
awaitItem().let {
|
||||
// Additional spurious state due to MediaPlayer owner change.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
awaitItem().let {
|
||||
// Additional spurious state due to MediaPlayer owner change.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
matchReadyState(1_000L)
|
||||
player2.play()
|
||||
awaitItem().let {
|
||||
// it plays until the end.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Play player1 again.
|
||||
player1.state.test {
|
||||
awaitItem().let {
|
||||
// Last previous state/
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
assertThat(player1.prepare().isSuccess).isTrue()
|
||||
awaitItem().let {
|
||||
// Additional spurious state due to MediaPlayer owner change.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
matchReadyState(1_000L)
|
||||
player1.play()
|
||||
awaitItem().let {
|
||||
// it played again until the end.
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
assertThat(it.duration).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause after play pauses`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.play()
|
||||
// skip play state
|
||||
skipItems(1)
|
||||
player.pause()
|
||||
awaitItem().let {
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play after pause works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.play()
|
||||
// skip play state
|
||||
skipItems(1)
|
||||
player.pause()
|
||||
// skip pause state
|
||||
skipItems(1)
|
||||
player.play()
|
||||
awaitItem().let {
|
||||
assertThat(it.isPlaying).isTrue()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seek before prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
player.seekTo(2000)
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isTrue()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seek after prepare works`() = runTest {
|
||||
val player = createDefaultVoiceMessagePlayer()
|
||||
player.state.test {
|
||||
matchInitialState()
|
||||
assertThat(player.prepare().isSuccess).isTrue()
|
||||
matchReadyState()
|
||||
player.seekTo(2000)
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isTrue()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(2000)
|
||||
assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val FAKE_TOTAL_DURATION_MS = 10_000L
|
||||
private const val FAKE_PLAYED_DURATION_MS = 1000L
|
||||
|
||||
private fun createDefaultVoiceMessagePlayer(
|
||||
mediaPlayer: MediaPlayer = FakeMediaPlayer(
|
||||
fakeTotalDurationMs = FAKE_TOTAL_DURATION_MS,
|
||||
fakePlayedDurationMs = FAKE_PLAYED_DURATION_MS
|
||||
),
|
||||
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||
eventId: EventId? = AN_EVENT_ID,
|
||||
) = DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
||||
eventId = eventId,
|
||||
mediaSource = MediaSource(
|
||||
url = MXC_URI,
|
||||
json = null
|
||||
),
|
||||
mimeType = MimeTypes.Ogg,
|
||||
filename = "someBody.ogg"
|
||||
)
|
||||
|
||||
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
||||
|
||||
private suspend fun TurbineTestContext<VoiceMessagePlayer.State>.matchInitialState() {
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isFalse()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun TurbineTestContext<VoiceMessagePlayer.State>.matchReadyState(
|
||||
fakeTotalDurationMs: Long = FAKE_TOTAL_DURATION_MS,
|
||||
) {
|
||||
awaitItem().let {
|
||||
assertThat(it.isReady).isTrue()
|
||||
assertThat(it.isPlaying).isFalse()
|
||||
assertThat(it.isEnded).isFalse()
|
||||
assertThat(it.currentPosition).isEqualTo(0)
|
||||
assertThat(it.duration).isEqualTo(fakeTotalDurationMs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A fake implementation of [VoiceMessageMediaRepo] for testing purposes.
|
||||
*/
|
||||
class FakeVoiceMessageMediaRepo : VoiceMessageMediaRepo {
|
||||
var shouldFail = false
|
||||
|
||||
override suspend fun getMediaFile(): Result<File> = simulateLongTask {
|
||||
if (shouldFail) {
|
||||
Result.failure(IllegalStateException("Failed to get media file"))
|
||||
} else {
|
||||
Result.success(File(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voiceplayer.impl
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class VoiceMessagePresenterTest {
|
||||
@Test
|
||||
fun `initial state has proper default values`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("1:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing play downloads and plays`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
duration = 2_000.milliseconds,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:00")
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing play downloads and fails`() = runTest {
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
||||
analyticsService = analyticsService,
|
||||
duration = 2_000.milliseconds,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
analyticsService.trackedErrors.first().also {
|
||||
assertThat(it).apply {
|
||||
isInstanceOf(VoiceMessageException.PlayMessageError::class.java)
|
||||
hasMessageThat().isEqualTo("Error while trying to play voice message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing pause while playing pauses`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
duration = 2_000.milliseconds,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:02")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
skipItems(2) // skip downloading states
|
||||
|
||||
val playingState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
|
||||
playingState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content with null eventId shows disabled button`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
eventId = null,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("1:01")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seeking before play`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||
duration = 10_000.milliseconds,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:10")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seeking after play`() = runTest {
|
||||
val presenter = createVoiceMessagePresenter(
|
||||
duration = 10_000.milliseconds,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||
assertThat(it.progress).isEqualTo(0f)
|
||||
assertThat(it.time).isEqualTo("0:10")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||
|
||||
skipItems(2) // skip downloading states
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.1f)
|
||||
assertThat(it.time).isEqualTo("0:01")
|
||||
}
|
||||
|
||||
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
|
||||
|
||||
awaitItem().also {
|
||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||
assertThat(it.progress).isEqualTo(0.5f)
|
||||
assertThat(it.time).isEqualTo("0:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TestScope.createVoiceMessagePresenter(
|
||||
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
|
||||
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
eventId: EventId? = EventId("\$anEventId"),
|
||||
filename: String = "filename doesn't really matter for a voice message",
|
||||
duration: Duration = 61_000.milliseconds,
|
||||
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
|
||||
mimeType: String = MimeTypes.Ogg,
|
||||
mediaSource: MediaSource = MediaSource(contentUri),
|
||||
) = VoiceMessagePresenter(
|
||||
analyticsService = analyticsService,
|
||||
sessionCoroutineScope = this,
|
||||
player = DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
||||
eventId = eventId,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename
|
||||
),
|
||||
eventId = eventId,
|
||||
duration = duration,
|
||||
)
|
||||
Reference in New Issue
Block a user