Merge branch 'develop' into langleyd/custom_waveform

This commit is contained in:
Marco Romano 2023-10-26 13:07:45 +02:00 committed by GitHub
commit eca7d705a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 562 additions and 454 deletions

View file

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

View file

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

View file

@ -20,6 +20,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
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
* a forward of a voice message),
* therefore the media uri is not enough to uniquely identify a voice message.
* This is why we must provide the eventId as well.
* 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 id of the voice message event. If null, a dummy
* player is returned.
* @param mediaPath The path to the voice message's media file.
* @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 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>
/**
* Start playing from the beginning acquiring control of the
* underlying [MediaPlayer].
* Starts playing from the beginning
* 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()
/**
* Start playing from the current position.
*/
fun play()
suspend fun play(): Result<Unit>
/**
* Pause playback.
@ -92,32 +98,45 @@ interface VoiceMessagePlayer {
}
/**
* An implementation of [VoiceMessagePlayer] which is backed by a [MediaPlayer]
* usually shared among different [VoiceMessagePlayer] instances.
*
* @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.
* 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 VoiceMessagePlayerImpl(
class DefaultVoiceMessagePlayer(
private val mediaPlayer: MediaPlayer,
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
private val eventId: EventId?,
private val mediaPath: String,
mediaSource: MediaSource,
mimeType: String?,
body: String?,
) : VoiceMessagePlayer {
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
class Factory @Inject constructor(
private val mediaPlayer: MediaPlayer,
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
) : VoiceMessagePlayer.Factory {
override fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayerImpl {
return VoiceMessagePlayerImpl(
mediaPlayer = mediaPlayer,
eventId = eventId,
mediaPath = mediaPath,
)
}
override fun create(
eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
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 ->
VoiceMessagePlayer.State(
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
@ -126,19 +145,20 @@ class VoiceMessagePlayerImpl(
)
}.distinctUntilChanged()
override fun acquireControlAndPlay() {
eventId?.let { eventId ->
mediaPlayer.acquireControlAndPlay(
uri = mediaPath,
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()
override suspend fun play(): Result<Unit> = if (inControl()) {
mediaPlayer.play()
Result.success(Unit)
} else {
if (eventId != null) {
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.
)
}
} 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 inline fun ifInControl(block: () -> Unit) {
if (mediaPlayer.state.value.mediaId.isMyTrack()) block()
if (inControl()) block()
}
private fun inControl(): Boolean = mediaPlayer.state.value.mediaId.isMyTrack()
}

View file

@ -37,9 +37,6 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
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 kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@ -54,9 +51,7 @@ interface VoiceMessagePresenterModule {
}
class VoiceMessagePresenter @AssistedInject constructor(
private val mediaLoader: MatrixMediaLoader,
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
voiceMessageCacheFactory: VoiceMessageCache.Factory,
@Assisted private val content: TimelineItemVoiceContent,
) : Presenter<VoiceMessageState> {
@ -65,11 +60,11 @@ class VoiceMessagePresenter @AssistedInject constructor(
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
}
private val voiceCache = voiceMessageCacheFactory.create(mxcUri = content.mediaSource.url)
private val player = voiceMessagePlayerFactory.create(
eventId = content.eventId,
mediaPath = voiceCache.cachePath
mediaSource = content.mediaSource,
mimeType = content.mimeType,
body = content.body,
)
@Composable
@ -78,15 +73,15 @@ class VoiceMessagePresenter @AssistedInject constructor(
val scope = rememberCoroutineScope()
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 {
derivedStateOf {
when {
content.eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
mediaFile.value is Async.Loading -> VoiceMessageState.Button.Downloading
mediaFile.value is Async.Failure -> VoiceMessageState.Button.Retry
play.value is Async.Loading -> VoiceMessageState.Button.Downloading
play.value is Async.Failure -> VoiceMessageState.Button.Retry
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) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
if (playerState.isMyMedia) {
if (playerState.isPlaying) {
player.pause()
} else {
player.play()
}
if (playerState.isPlaying) {
player.pause()
} else {
if (voiceCache.isInCache()) {
player.acquireControlAndPlay()
} else {
scope.launch { downloadCacheAndPlay() }
}
scope.launch { play.runUpdatingState { player.play() } }
}
}
is VoiceMessageEvents.Seek -> {