First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View 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)
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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>
}

View File

@@ -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,
}
}

View File

@@ -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 = {},
)

View 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)
}

View File

@@ -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,
)
}
}

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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() }

View File

@@ -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)
}
}

View File

@@ -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(""))
}
}
}

View 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,
)