Merge branch 'develop' into langleyd/custom_waveform
This commit is contained in:
commit
10ce75f74e
10 changed files with 562 additions and 454 deletions
|
|
@ -1,123 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedFactory
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import io.element.android.libraries.di.AppScope
|
|
||||||
import io.element.android.libraries.di.CacheDirectory
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the local disk cache for a voice message.
|
|
||||||
*/
|
|
||||||
interface VoiceMessageCache {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory for [VoiceMessageCache].
|
|
||||||
*/
|
|
||||||
fun interface Factory {
|
|
||||||
/**
|
|
||||||
* Creates a [VoiceMessageCache] for the given Matrix Content (mxc://) URI.
|
|
||||||
*
|
|
||||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
|
||||||
*/
|
|
||||||
fun create(mxcUri: String): VoiceMessageCache
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The file path of the voice message in the cache directory.
|
|
||||||
* NB: This doesn't necessarily mean that the file exists.
|
|
||||||
*
|
|
||||||
* @return the file path of the voice message in the cache directory.
|
|
||||||
*/
|
|
||||||
val cachePath: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the voice message is in the cache directory.
|
|
||||||
*
|
|
||||||
* @return true if the voice message is in the cache directory.
|
|
||||||
*/
|
|
||||||
fun isInCache(): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the file to the voice cache directory.
|
|
||||||
*
|
|
||||||
* @return true if the file was successfully moved.
|
|
||||||
*/
|
|
||||||
fun moveToCache(file: File): Boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default implementation of [VoiceMessageCache].
|
|
||||||
*
|
|
||||||
* NB: All methods will throw an [IllegalStateException] if the mxcUri is invalid.
|
|
||||||
*
|
|
||||||
* @param cacheDir the application's cache directory.
|
|
||||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
|
||||||
*/
|
|
||||||
class VoiceMessageCacheImpl @AssistedInject constructor(
|
|
||||||
@CacheDirectory private val cacheDir: File,
|
|
||||||
@Assisted private val mxcUri: String,
|
|
||||||
) : VoiceMessageCache {
|
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
|
||||||
@AssistedFactory
|
|
||||||
fun interface Factory : VoiceMessageCache.Factory {
|
|
||||||
override fun create(mxcUri: String): VoiceMessageCacheImpl
|
|
||||||
}
|
|
||||||
|
|
||||||
override val cachePath: String = "${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mxcUri)}"
|
|
||||||
|
|
||||||
override fun isInCache(): Boolean = File(cachePath).exists()
|
|
||||||
|
|
||||||
override fun moveToCache(file: File): Boolean {
|
|
||||||
val dest = File(cachePath).apply { parentFile?.mkdirs() }
|
|
||||||
return file.renameTo(dest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subdirectory of the application's cache directory where voice messages are stored.
|
|
||||||
*/
|
|
||||||
private const val CACHE_VOICE_SUBDIR = "temp/voice"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regex to match a Matrix Content (mxc://) URI.
|
|
||||||
*
|
|
||||||
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
|
|
||||||
*/
|
|
||||||
private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes an mxcUri to be used as a relative file path.
|
|
||||||
*
|
|
||||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
|
||||||
* @return the relative file path as "<server-name>/<media-id>".
|
|
||||||
* @throws IllegalStateException if the mxcUri is invalid.
|
|
||||||
*/
|
|
||||||
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
|
|
||||||
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
|
|
||||||
}.let { match ->
|
|
||||||
buildString {
|
|
||||||
append(match.groupValues[1])
|
|
||||||
append("/")
|
|
||||||
append(match.groupValues[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
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.media.toFile
|
||||||
|
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 body the body of the voice message.
|
||||||
|
*/
|
||||||
|
fun create(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mimeType: String?,
|
||||||
|
body: 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
|
||||||
|
@CacheDirectory private val cacheDir: File,
|
||||||
|
private val matrixMediaLoader: MatrixMediaLoader,
|
||||||
|
@Assisted private val mediaSource: MediaSource,
|
||||||
|
@Assisted("mimeType") private val mimeType: String?,
|
||||||
|
@Assisted("body") private val body: String?,
|
||||||
|
) : VoiceMessageMediaRepo {
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
@AssistedFactory
|
||||||
|
fun interface Factory : VoiceMessageMediaRepo.Factory {
|
||||||
|
override fun create(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
@Assisted("mimeType") mimeType: String?,
|
||||||
|
@Assisted("body") body: String?,
|
||||||
|
): DefaultVoiceMessageMediaRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMediaFile(): Result<File> = if (!isInCache()) {
|
||||||
|
matrixMediaLoader.downloadMediaFile(
|
||||||
|
source = mediaSource,
|
||||||
|
mimeType = mimeType,
|
||||||
|
body = body,
|
||||||
|
).mapCatching {
|
||||||
|
val dest = cachedFilePath.apply { parentFile?.mkdirs() }
|
||||||
|
// TODO By not closing the MediaFile we're leaking the rust file handle here.
|
||||||
|
// Not that big of a deal but better to avoid it someday.
|
||||||
|
if (it.toFile().renameTo(dest)) {
|
||||||
|
dest
|
||||||
|
} else {
|
||||||
|
error("Failed to move file to cache.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Result.success(cachedFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cachedFilePath: File = File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mediaSource.url)}")
|
||||||
|
|
||||||
|
private fun isInCache(): Boolean = cachedFilePath.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subdirectory of the application's cache directory where voice messages are stored.
|
||||||
|
*/
|
||||||
|
private const val CACHE_VOICE_SUBDIR = "temp/voice"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex to match a Matrix Content (mxc://) URI.
|
||||||
|
*
|
||||||
|
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
|
||||||
|
*/
|
||||||
|
private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes an mxcUri to be used as a relative file path.
|
||||||
|
*
|
||||||
|
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
||||||
|
* @return the relative file path as "<server-name>/<media-id>".
|
||||||
|
* @throws IllegalStateException if the mxcUri is invalid.
|
||||||
|
*/
|
||||||
|
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
|
||||||
|
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
|
||||||
|
}.let { match ->
|
||||||
|
buildString {
|
||||||
|
append(match.groupValues[1])
|
||||||
|
append("/")
|
||||||
|
append(match.groupValues[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
|
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
@ -37,14 +38,20 @@ interface VoiceMessagePlayer {
|
||||||
*
|
*
|
||||||
* NB: Different voice messages can use the same content uri (e.g. in case of
|
* NB: Different voice messages can use the same content uri (e.g. in case of
|
||||||
* a forward of a voice message),
|
* a forward of a voice message),
|
||||||
* therefore the media uri is not enough to uniquely identify a voice message.
|
* therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
|
||||||
* This is why we must provide the eventId as well.
|
* a voice message. This is why we must provide the eventId as well.
|
||||||
*
|
*
|
||||||
* @param eventId The id of the voice message event. If null, a dummy
|
* @param eventId The eventId of the voice message event.
|
||||||
* player is returned.
|
* @param mediaSource The media source of the voice message.
|
||||||
* @param mediaPath The path to the voice message's media file.
|
* @param mimeType The mime type of the voice message.
|
||||||
|
* @param body The body of the voice message.
|
||||||
*/
|
*/
|
||||||
fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayer
|
fun create(
|
||||||
|
eventId: EventId?,
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mimeType: String?,
|
||||||
|
body: String?,
|
||||||
|
): VoiceMessagePlayer
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -53,15 +60,14 @@ interface VoiceMessagePlayer {
|
||||||
val state: Flow<State>
|
val state: Flow<State>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start playing from the beginning acquiring control of the
|
* Starts playing from the beginning
|
||||||
* underlying [MediaPlayer].
|
* acquiring control of the underlying [MediaPlayer].
|
||||||
|
* If already in control of the underlying [MediaPlayer], starts playing from the
|
||||||
|
* current position.
|
||||||
|
*
|
||||||
|
* Will suspend whilst the media file is being downloaded.
|
||||||
*/
|
*/
|
||||||
fun acquireControlAndPlay()
|
suspend fun play(): Result<Unit>
|
||||||
|
|
||||||
/**
|
|
||||||
* Start playing from the current position.
|
|
||||||
*/
|
|
||||||
fun play()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pause playback.
|
* Pause playback.
|
||||||
|
|
@ -92,32 +98,45 @@ interface VoiceMessagePlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An implementation of [VoiceMessagePlayer] which is backed by a [MediaPlayer]
|
* An implementation of [VoiceMessagePlayer] which is backed by a
|
||||||
* usually shared among different [VoiceMessagePlayer] instances.
|
* [VoiceMessageMediaRepo] to fetch and cache the media file and
|
||||||
*
|
* which uses a global [MediaPlayer] instance to play the media.
|
||||||
* @param mediaPlayer The [MediaPlayer] to use.
|
|
||||||
* @param eventId The id of the voice message event. If null, the player will behave as no-op.
|
|
||||||
* @param mediaPath The path to the voice message's media file.
|
|
||||||
*/
|
*/
|
||||||
class VoiceMessagePlayerImpl(
|
class DefaultVoiceMessagePlayer(
|
||||||
private val mediaPlayer: MediaPlayer,
|
private val mediaPlayer: MediaPlayer,
|
||||||
|
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||||
private val eventId: EventId?,
|
private val eventId: EventId?,
|
||||||
private val mediaPath: String,
|
mediaSource: MediaSource,
|
||||||
|
mimeType: String?,
|
||||||
|
body: String?,
|
||||||
) : VoiceMessagePlayer {
|
) : VoiceMessagePlayer {
|
||||||
|
|
||||||
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
|
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
|
||||||
class Factory @Inject constructor(
|
class Factory @Inject constructor(
|
||||||
private val mediaPlayer: MediaPlayer,
|
private val mediaPlayer: MediaPlayer,
|
||||||
|
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||||
) : VoiceMessagePlayer.Factory {
|
) : VoiceMessagePlayer.Factory {
|
||||||
override fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayerImpl {
|
override fun create(
|
||||||
return VoiceMessagePlayerImpl(
|
eventId: EventId?,
|
||||||
mediaPlayer = mediaPlayer,
|
mediaSource: MediaSource,
|
||||||
eventId = eventId,
|
mimeType: String?,
|
||||||
mediaPath = mediaPath,
|
body: String?,
|
||||||
)
|
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
|
||||||
}
|
mediaPlayer = mediaPlayer,
|
||||||
|
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
|
||||||
|
eventId = eventId,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
mimeType = mimeType,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val repo = voiceMessageMediaRepoFactory.create(
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
mimeType = mimeType,
|
||||||
|
body = body
|
||||||
|
)
|
||||||
|
|
||||||
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state ->
|
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state ->
|
||||||
VoiceMessagePlayer.State(
|
VoiceMessagePlayer.State(
|
||||||
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
|
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
|
||||||
|
|
@ -126,19 +145,20 @@ class VoiceMessagePlayerImpl(
|
||||||
)
|
)
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
override fun acquireControlAndPlay() {
|
override suspend fun play(): Result<Unit> = if (inControl()) {
|
||||||
eventId?.let { eventId ->
|
mediaPlayer.play()
|
||||||
mediaPlayer.acquireControlAndPlay(
|
Result.success(Unit)
|
||||||
uri = mediaPath,
|
} else {
|
||||||
mediaId = eventId.value,
|
if (eventId != null) {
|
||||||
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
repo.getMediaFile().mapCatching { mediaFile ->
|
||||||
)
|
mediaPlayer.acquireControlAndPlay(
|
||||||
}
|
uri = mediaFile.path,
|
||||||
}
|
mediaId = eventId.value,
|
||||||
|
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||||
override fun play() {
|
)
|
||||||
ifInControl {
|
}
|
||||||
mediaPlayer.play()
|
} else {
|
||||||
|
Result.failure(IllegalStateException("Cannot play a voice message with no eventId"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +177,8 @@ class VoiceMessagePlayerImpl(
|
||||||
private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value
|
private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value
|
||||||
|
|
||||||
private inline fun ifInControl(block: () -> Unit) {
|
private inline fun ifInControl(block: () -> Unit) {
|
||||||
if (mediaPlayer.state.value.mediaId.isMyTrack()) block()
|
if (inControl()) block()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun inControl(): Boolean = mediaPlayer.state.value.mediaId.isMyTrack()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,6 @@ import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runUpdatingState
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
|
||||||
import io.element.android.libraries.matrix.api.media.toFile
|
|
||||||
import io.element.android.libraries.ui.utils.time.formatShort
|
import io.element.android.libraries.ui.utils.time.formatShort
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
@ -54,9 +51,7 @@ interface VoiceMessagePresenterModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
class VoiceMessagePresenter @AssistedInject constructor(
|
class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
private val mediaLoader: MatrixMediaLoader,
|
|
||||||
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
||||||
voiceMessageCacheFactory: VoiceMessageCache.Factory,
|
|
||||||
@Assisted private val content: TimelineItemVoiceContent,
|
@Assisted private val content: TimelineItemVoiceContent,
|
||||||
) : Presenter<VoiceMessageState> {
|
) : Presenter<VoiceMessageState> {
|
||||||
|
|
||||||
|
|
@ -65,11 +60,11 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
|
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
private val voiceCache = voiceMessageCacheFactory.create(mxcUri = content.mediaSource.url)
|
|
||||||
|
|
||||||
private val player = voiceMessagePlayerFactory.create(
|
private val player = voiceMessagePlayerFactory.create(
|
||||||
eventId = content.eventId,
|
eventId = content.eventId,
|
||||||
mediaPath = voiceCache.cachePath
|
mediaSource = content.mediaSource,
|
||||||
|
mimeType = content.mimeType,
|
||||||
|
body = content.body,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -78,15 +73,15 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L))
|
val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L))
|
||||||
val mediaFile = remember { mutableStateOf<Async<MediaFile>>(Async.Uninitialized) }
|
val play = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
|
||||||
|
|
||||||
val button by remember {
|
val button by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
when {
|
when {
|
||||||
content.eventId == null -> VoiceMessageState.Button.Disabled
|
content.eventId == null -> VoiceMessageState.Button.Disabled
|
||||||
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
||||||
mediaFile.value is Async.Loading -> VoiceMessageState.Button.Downloading
|
play.value is Async.Loading -> VoiceMessageState.Button.Downloading
|
||||||
mediaFile.value is Async.Failure -> VoiceMessageState.Button.Retry
|
play.value is Async.Failure -> VoiceMessageState.Button.Retry
|
||||||
else -> VoiceMessageState.Button.Play
|
else -> VoiceMessageState.Button.Play
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,38 +96,13 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadCacheAndPlay() {
|
|
||||||
mediaFile.runUpdatingState {
|
|
||||||
mediaLoader.downloadMediaFile(
|
|
||||||
source = content.mediaSource,
|
|
||||||
mimeType = content.mimeType,
|
|
||||||
body = content.body,
|
|
||||||
).mapCatching {
|
|
||||||
if (voiceCache.moveToCache(it.toFile())) {
|
|
||||||
player.acquireControlAndPlay()
|
|
||||||
it
|
|
||||||
} else {
|
|
||||||
error("Failed to move file to cache.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eventSink(event: VoiceMessageEvents) {
|
fun eventSink(event: VoiceMessageEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is VoiceMessageEvents.PlayPause -> {
|
is VoiceMessageEvents.PlayPause -> {
|
||||||
if (playerState.isMyMedia) {
|
if (playerState.isPlaying) {
|
||||||
if (playerState.isPlaying) {
|
player.pause()
|
||||||
player.pause()
|
|
||||||
} else {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (voiceCache.isInCache()) {
|
scope.launch { play.runUpdatingState { player.play() } }
|
||||||
player.acquireControlAndPlay()
|
|
||||||
} else {
|
|
||||||
scope.launch { downloadCacheAndPlay() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is VoiceMessageEvents.Seek -> {
|
is VoiceMessageEvents.Seek -> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.voicemessages.timeline
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessageMediaRepo
|
||||||
|
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.FakeMediaLoader
|
||||||
|
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 fakeMediaLoader = FakeMediaLoader().apply {
|
||||||
|
path = temporaryFolder.createRustMediaFile().path
|
||||||
|
}
|
||||||
|
val repo = createDefaultVoiceMessageMediaRepo(
|
||||||
|
temporaryFolder = temporaryFolder,
|
||||||
|
matrixMediaLoader = fakeMediaLoader,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.getMediaFile().let { result ->
|
||||||
|
Truth.assertThat(result.isSuccess).isTrue()
|
||||||
|
result.getOrThrow().let { file ->
|
||||||
|
Truth.assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
|
||||||
|
Truth.assertThat(file.exists()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cache miss - download fails`() = runTest {
|
||||||
|
val fakeMediaLoader = FakeMediaLoader().apply {
|
||||||
|
shouldFail = true
|
||||||
|
}
|
||||||
|
val repo = createDefaultVoiceMessageMediaRepo(
|
||||||
|
temporaryFolder = temporaryFolder,
|
||||||
|
matrixMediaLoader = fakeMediaLoader,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.getMediaFile().let { result ->
|
||||||
|
Truth.assertThat(result.isFailure).isTrue()
|
||||||
|
result.exceptionOrNull()?.let { exception ->
|
||||||
|
Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cache miss - download succeeds but file move fails`() = runTest {
|
||||||
|
val fakeMediaLoader = FakeMediaLoader().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 = fakeMediaLoader,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.getMediaFile().let { result ->
|
||||||
|
Truth.assertThat(result.isFailure).isTrue()
|
||||||
|
result.exceptionOrNull()?.let { exception ->
|
||||||
|
Truth.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 fakeMediaLoader = FakeMediaLoader().apply {
|
||||||
|
shouldFail = true // so that if we hit the media loader it will crash
|
||||||
|
}
|
||||||
|
val repo = createDefaultVoiceMessageMediaRepo(
|
||||||
|
temporaryFolder = temporaryFolder,
|
||||||
|
matrixMediaLoader = fakeMediaLoader,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.getMediaFile().let { result ->
|
||||||
|
Truth.assertThat(result.isSuccess).isTrue()
|
||||||
|
result.getOrThrow().let { file ->
|
||||||
|
Truth.assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
|
||||||
|
Truth.assertThat(file.exists()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDefaultVoiceMessageMediaRepo(
|
||||||
|
temporaryFolder: TemporaryFolder,
|
||||||
|
matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(),
|
||||||
|
) = DefaultVoiceMessageMediaRepo(
|
||||||
|
cacheDir = temporaryFolder.root,
|
||||||
|
matrixMediaLoader = matrixMediaLoader,
|
||||||
|
mediaSource = MediaSource(
|
||||||
|
url = MXC_URI,
|
||||||
|
json = null
|
||||||
|
),
|
||||||
|
mimeType = "audio/ogg",
|
||||||
|
body = "someBody.ogg"
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
||||||
|
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,149 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.voicemessages.timeline
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessagePlayer
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo
|
||||||
|
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer
|
||||||
|
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 kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultVoiceMessagePlayerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state`() = runTest {
|
||||||
|
createDefaultVoiceMessagePlayer().state.test {
|
||||||
|
awaitItem().let {
|
||||||
|
Truth.assertThat(it.isPlaying).isEqualTo(false)
|
||||||
|
Truth.assertThat(it.isMyMedia).isEqualTo(false)
|
||||||
|
Truth.assertThat(it.currentPosition).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `downloading and play works`() = runTest {
|
||||||
|
val player = createDefaultVoiceMessagePlayer()
|
||||||
|
player.state.test {
|
||||||
|
skipItems(1) // skip initial state.
|
||||||
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
|
awaitItem().let {
|
||||||
|
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.currentPosition).isEqualTo(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `downloading and play fails`() = runTest {
|
||||||
|
val player = createDefaultVoiceMessagePlayer(
|
||||||
|
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
|
||||||
|
shouldFail = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
player.state.test {
|
||||||
|
skipItems(1) // skip initial state.
|
||||||
|
Truth.assertThat(player.play().isFailure).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play fails with no eventId`() = runTest {
|
||||||
|
val player = createDefaultVoiceMessagePlayer(
|
||||||
|
eventId = null
|
||||||
|
)
|
||||||
|
player.state.test {
|
||||||
|
skipItems(1) // skip initial state.
|
||||||
|
Truth.assertThat(player.play().isFailure).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pause playing works`() = runTest {
|
||||||
|
val player = createDefaultVoiceMessagePlayer()
|
||||||
|
player.state.test {
|
||||||
|
skipItems(1) // skip initial state.
|
||||||
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
|
skipItems(1) // skip play state
|
||||||
|
player.pause()
|
||||||
|
awaitItem().let {
|
||||||
|
Truth.assertThat(it.isPlaying).isEqualTo(false)
|
||||||
|
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.currentPosition).isEqualTo(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `play after pause works`() = runTest {
|
||||||
|
val player = createDefaultVoiceMessagePlayer()
|
||||||
|
player.state.test {
|
||||||
|
skipItems(1) // skip initial state.
|
||||||
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
|
skipItems(1) // skip play state
|
||||||
|
player.pause()
|
||||||
|
skipItems(1)
|
||||||
|
player.play()
|
||||||
|
awaitItem().let {
|
||||||
|
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.currentPosition).isEqualTo(2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `seek to works`() = runTest {
|
||||||
|
val player = createDefaultVoiceMessagePlayer()
|
||||||
|
player.state.test {
|
||||||
|
skipItems(1) // skip initial state.
|
||||||
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
|
skipItems(1) // skip play state
|
||||||
|
player.seekTo(2000)
|
||||||
|
awaitItem().let {
|
||||||
|
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.currentPosition).isEqualTo(2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDefaultVoiceMessagePlayer(
|
||||||
|
mediaPlayer: MediaPlayer = FakeMediaPlayer(),
|
||||||
|
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||||
|
eventId: EventId? = AN_EVENT_ID,
|
||||||
|
) = DefaultVoiceMessagePlayer(
|
||||||
|
mediaPlayer = mediaPlayer,
|
||||||
|
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
||||||
|
eventId = eventId,
|
||||||
|
mediaSource = MediaSource(
|
||||||
|
url = MXC_URI,
|
||||||
|
json = null
|
||||||
|
),
|
||||||
|
mimeType = "audio/ogg",
|
||||||
|
body = "someBody.ogg"
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
||||||
|
|
@ -16,34 +16,22 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.voicemessages.timeline
|
package io.element.android.features.messages.voicemessages.timeline
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCache
|
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fake implementation of [VoiceMessageCache] for testing purposes.
|
* A fake implementation of [VoiceMessageMediaRepo] for testing purposes.
|
||||||
*/
|
*/
|
||||||
class FakeVoiceMessageCache : VoiceMessageCache {
|
class FakeVoiceMessageMediaRepo : VoiceMessageMediaRepo {
|
||||||
|
|
||||||
private var _cachePath: String = ""
|
var shouldFail = false
|
||||||
private var _isInCache: Boolean = false
|
|
||||||
private var _moveToCache: Boolean = false
|
|
||||||
|
|
||||||
override val cachePath: String
|
override suspend fun getMediaFile(): Result<File> = simulateLongTask {
|
||||||
get() = _cachePath
|
if (shouldFail) {
|
||||||
|
Result.failure(IllegalStateException("Failed to get media file"))
|
||||||
override fun isInCache(): Boolean = _isInCache
|
} else {
|
||||||
|
Result.success(File(""))
|
||||||
override fun moveToCache(file: File): Boolean = _moveToCache
|
}
|
||||||
|
|
||||||
fun givenCachePath(cachePath: String) {
|
|
||||||
_cachePath = cachePath
|
|
||||||
}
|
|
||||||
|
|
||||||
fun givenIsInCache(isInCache: Boolean) {
|
|
||||||
_isInCache = isInCache
|
|
||||||
}
|
|
||||||
|
|
||||||
fun givenMoveToCache(moveToCache: Boolean) {
|
|
||||||
_moveToCache = moveToCache
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.messages.voicemessages.timeline
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth
|
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCacheImpl
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.rules.TemporaryFolder
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class VoiceMessageCacheTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val temporaryFolder = TemporaryFolder()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `moveToVoiceCache() should move the file to the voice cache dir`() {
|
|
||||||
val rootPath = temporaryFolder.root.path
|
|
||||||
val file = File("$rootPath/myFile.txt").apply { createNewFile() }
|
|
||||||
val cacheDir = File("$rootPath/cacheDir").apply { if (!exists()) mkdirs() }
|
|
||||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
|
|
||||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
|
|
||||||
|
|
||||||
Truth.assertThat(cache.moveToCache(file))
|
|
||||||
.isTrue()
|
|
||||||
Truth.assertThat(File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").exists())
|
|
||||||
.isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `voiceCachePath() should point to cacheDir-temp-voice-mxcUri2fileName`() {
|
|
||||||
val rootPath = temporaryFolder.root.path
|
|
||||||
val cacheDir = File("$rootPath/cacheDir")
|
|
||||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
|
|
||||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
|
|
||||||
|
|
||||||
Truth.assertThat(cache.cachePath)
|
|
||||||
.isEqualTo("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `isInVoiceCache() should return true if the file exists`() {
|
|
||||||
val rootPath = temporaryFolder.root.path
|
|
||||||
val cacheDir = File("$rootPath/cacheDir")
|
|
||||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
|
|
||||||
val file = File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").apply {
|
|
||||||
parentFile?.mkdirs()
|
|
||||||
createNewFile()
|
|
||||||
}
|
|
||||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
|
|
||||||
|
|
||||||
Truth.assertThat(cache.isInCache())
|
|
||||||
.isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `isInVoiceCache() should return false if the file does not exist`() {
|
|
||||||
val rootPath = temporaryFolder.root.path
|
|
||||||
val cacheDir = File("$rootPath/cacheDir")
|
|
||||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
|
|
||||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
|
|
||||||
|
|
||||||
Truth.assertThat(cache.isInCache())
|
|
||||||
.isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(expected = IllegalStateException::class)
|
|
||||||
fun `isInVoiceCache() throws IllegalStateException on bogus mxc uri`() {
|
|
||||||
val cacheDir = File("")
|
|
||||||
val mxcUri = "bogus"
|
|
||||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
|
|
||||||
|
|
||||||
cache.isInCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,23 +22,19 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer
|
import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessagePlayer
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
|
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePlayerImpl
|
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePresenter
|
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePresenter
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class VoiceMessagePresenterTest {
|
class VoiceMessagePresenterTest {
|
||||||
|
|
||||||
private val fakeMediaLoader = FakeMediaLoader()
|
|
||||||
private val fakeVoiceCache = FakeVoiceMessageCache()
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state has proper default values`() = runTest {
|
fun `initial state has proper default values`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache)
|
val presenter = createVoiceMessagePresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -51,12 +47,10 @@ class VoiceMessagePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pressing play with file in cache plays`() = runTest {
|
fun `pressing play downloads and plays`() = runTest {
|
||||||
fakeVoiceCache.apply {
|
val presenter = createVoiceMessagePresenter(
|
||||||
givenIsInCache(true)
|
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
||||||
}
|
)
|
||||||
val content = aTimelineItemVoiceContent(durationMs = 2_000)
|
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -68,6 +62,11 @@ class VoiceMessagePresenterTest {
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
|
|
||||||
|
awaitItem().also {
|
||||||
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||||
|
Truth.assertThat(it.progress).isEqualTo(0f)
|
||||||
|
Truth.assertThat(it.time).isEqualTo("0:02")
|
||||||
|
}
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
||||||
|
|
@ -77,22 +76,18 @@ class VoiceMessagePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pressing play with file not in cache downloads it but fails`() = runTest {
|
fun `pressing play downloads and fails`() = runTest {
|
||||||
fakeMediaLoader.apply {
|
val presenter = createVoiceMessagePresenter(
|
||||||
shouldFail = true
|
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
||||||
}
|
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
||||||
fakeVoiceCache.apply {
|
)
|
||||||
givenIsInCache(false)
|
|
||||||
givenMoveToCache(true)
|
|
||||||
}
|
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem().also {
|
val initialState = awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
Truth.assertThat(it.progress).isEqualTo(0f)
|
||||||
Truth.assertThat(it.time).isEqualTo("1:01")
|
Truth.assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
|
|
@ -100,59 +95,21 @@ class VoiceMessagePresenterTest {
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
Truth.assertThat(it.progress).isEqualTo(0f)
|
||||||
Truth.assertThat(it.time).isEqualTo("1:01")
|
Truth.assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
Truth.assertThat(it.progress).isEqualTo(0f)
|
||||||
Truth.assertThat(it.time).isEqualTo("1:01")
|
Truth.assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pressing play with file not in cache downloads it but then caching fails`() = runTest {
|
fun `pressing pause while playing pauses`() = runTest {
|
||||||
fakeMediaLoader.apply {
|
val presenter = createVoiceMessagePresenter(
|
||||||
shouldFail = false
|
content = aTimelineItemVoiceContent(durationMs = 2_000),
|
||||||
}
|
)
|
||||||
fakeVoiceCache.apply {
|
|
||||||
givenIsInCache(false)
|
|
||||||
givenMoveToCache(false)
|
|
||||||
}
|
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem().also {
|
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
|
||||||
Truth.assertThat(it.time).isEqualTo("1:01")
|
|
||||||
}
|
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
|
||||||
|
|
||||||
awaitItem().also {
|
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
|
||||||
Truth.assertThat(it.time).isEqualTo("1:01")
|
|
||||||
}
|
|
||||||
|
|
||||||
awaitItem().also {
|
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
|
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
|
||||||
Truth.assertThat(it.time).isEqualTo("1:01")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `acquire control then play then play and pause while having control`() = runTest {
|
|
||||||
fakeVoiceCache.apply {
|
|
||||||
givenIsInCache(true)
|
|
||||||
}
|
|
||||||
val content = aTimelineItemVoiceContent(durationMs = 2_000)
|
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -163,78 +120,28 @@ class VoiceMessagePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
|
skipItems(1) // skip downloading state
|
||||||
|
|
||||||
awaitItem().also {
|
val playingState = awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
||||||
Truth.assertThat(it.time).isEqualTo("0:01")
|
Truth.assertThat(it.time).isEqualTo("0:01")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
playingState.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
||||||
Truth.assertThat(it.time).isEqualTo("0:01")
|
Truth.assertThat(it.time).isEqualTo("0:01")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
|
||||||
|
|
||||||
awaitItem().also {
|
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
|
||||||
Truth.assertThat(it.progress).isEqualTo(1.0f)
|
|
||||||
Truth.assertThat(it.time).isEqualTo("0:02")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `pressing play with file not in cache downloads it successfully`() = runTest {
|
|
||||||
fakeMediaLoader.apply {
|
|
||||||
shouldFail = false
|
|
||||||
}
|
|
||||||
fakeVoiceCache.apply {
|
|
||||||
givenIsInCache(false)
|
|
||||||
givenMoveToCache(true)
|
|
||||||
}
|
|
||||||
val content = aTimelineItemVoiceContent(durationMs = 2_000)
|
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem().also {
|
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
|
||||||
Truth.assertThat(it.time).isEqualTo("0:02")
|
|
||||||
}
|
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
|
||||||
|
|
||||||
awaitItem().also {
|
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
|
||||||
Truth.assertThat(it.time).isEqualTo("0:02")
|
|
||||||
}
|
|
||||||
|
|
||||||
awaitItem().also {
|
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
|
||||||
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
|
||||||
Truth.assertThat(it.time).isEqualTo("0:01")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `content with null eventId shows disabled button`() = runTest {
|
fun `content with null eventId shows disabled button`() = runTest {
|
||||||
fakeMediaLoader.apply {
|
val presenter = createVoiceMessagePresenter(
|
||||||
shouldFail = false
|
content = aTimelineItemVoiceContent(eventId = null),
|
||||||
}
|
)
|
||||||
fakeVoiceCache.apply {
|
|
||||||
givenIsInCache(false)
|
|
||||||
givenMoveToCache(true)
|
|
||||||
}
|
|
||||||
val content = aTimelineItemVoiceContent(eventId = null)
|
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -248,12 +155,9 @@ class VoiceMessagePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `seeking seeks`() = runTest {
|
fun `seeking seeks`() = runTest {
|
||||||
fakeVoiceCache.apply {
|
val presenter = createVoiceMessagePresenter(
|
||||||
givenIsInCache(true)
|
content = aTimelineItemVoiceContent(durationMs = 10_000),
|
||||||
}
|
)
|
||||||
val content = aTimelineItemVoiceContent(durationMs = 10_000)
|
|
||||||
|
|
||||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -265,6 +169,8 @@ class VoiceMessagePresenterTest {
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
|
|
||||||
|
skipItems(1) // skip downloading state
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0.1f)
|
Truth.assertThat(it.progress).isEqualTo(0.1f)
|
||||||
|
|
@ -283,12 +189,18 @@ class VoiceMessagePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createVoiceMessagePresenter(
|
fun createVoiceMessagePresenter(
|
||||||
fakeMediaLoader: FakeMediaLoader,
|
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||||
voiceCacheFake: FakeVoiceMessageCache,
|
|
||||||
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
|
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
|
||||||
) = VoiceMessagePresenter(
|
) = VoiceMessagePresenter(
|
||||||
mediaLoader = fakeMediaLoader,
|
voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, body ->
|
||||||
voiceMessagePlayerFactory = { eventId, mediaPath -> VoiceMessagePlayerImpl(FakeMediaPlayer(), eventId, mediaPath) },
|
DefaultVoiceMessagePlayer(
|
||||||
voiceMessageCacheFactory = { voiceCacheFake },
|
mediaPlayer = FakeMediaPlayer(),
|
||||||
|
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
||||||
|
eventId = eventId,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
mimeType = mimeType,
|
||||||
|
body = body
|
||||||
|
)
|
||||||
|
},
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import io.element.android.tests.testutils.simulateLongTask
|
||||||
class FakeMediaLoader : MatrixMediaLoader {
|
class FakeMediaLoader : MatrixMediaLoader {
|
||||||
|
|
||||||
var shouldFail = false
|
var shouldFail = false
|
||||||
|
var path: String = ""
|
||||||
|
|
||||||
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = simulateLongTask {
|
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = simulateLongTask {
|
||||||
if (shouldFail) {
|
if (shouldFail) {
|
||||||
|
|
@ -45,7 +46,7 @@ class FakeMediaLoader : MatrixMediaLoader {
|
||||||
if (shouldFail) {
|
if (shouldFail) {
|
||||||
Result.failure(RuntimeException())
|
Result.failure(RuntimeException())
|
||||||
} else {
|
} else {
|
||||||
Result.success(FakeMediaFile(""))
|
Result.success(FakeMediaFile(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue