Receive and play a voice message (#1503)

## Type of change

- [x] Feature
- [ ] Bugfix
- [ ] Technical
- [ ] Other :

## Content

This PR consists of several macro-blocks separated by path/package:
- `messages.impl.mediaplayer` : Global (room-wide) media player, now used only for voice messages but could be used for all media within EX in the future. It is backed by media3's exoplayer. Currently not unit-tested because mocking exoplayer is not trivial.
- `messages.impl.voicemessages.play` : Business logic of a timeline voice message. This is all the logic that manages the voice message bubble.
- `messages.impl.timeline.model` & `messages.impl.timeline.factories`: Timeline code that takes care of creating the `content` object for voice messages.
-  `messages.impl.timeline.components` : The actual View composable that shows the UI inside a voice message bubble.

All the rest is just small related changes that must be done here and there in existing code.

From a high level perspective this is how it works:
- Voice messages are unlike other message bubbles because they carry state (i.e. playing, downloading...) so they have a Presenter managing this state.
- Media content (i.e. the ogg file) of a voice message is downloaded from the rust SDK on first play then stored in a voice messages cache (see the `VoiceMessageCache` class, it is just a subdirectory in the app's cacheDir which is indexed by the matrix content uri). All further play attempts are done from the cache without hitting the rust SDK anymore.
- Playback of the ogg file is handled with the `VoiceMessagePlayer` class which is basically a "view" of the global `MediaPlayer` that allow the voice message to only see the media player state belonging to its media content. 
- Drawing of the waveform is done with an OSS library wrapped in the `WaveformProgressIndicator` composable.

Known issues:
 - The waveform has no position slider.
 - The waveform (and together with it the whole message bubble) is taller than the actual Figma design.
 - Swipe to reply for voice messages is disabled to avoid conflict with the audio scrubbing gesture (to reply to a voice message you have to use the long press menu).
 - The loading indicator is always shown (there is no delay).
 - Voice messages don't stop playing when redacted.

## Motivation and context

https://github.com/vector-im/element-meta/issues/2083

## Screenshots / GIFs

Provided by Screenshot tests in the PR itself.
This commit is contained in:
Marco Romano 2023-10-24 23:47:51 +02:00 committed by GitHub
parent 8c7a0c0e0a
commit 6e66c989f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2115 additions and 12 deletions

1
changelog.d/2084.feature Normal file
View file

@ -0,0 +1 @@
Receive and play a voice message

View file

@ -52,6 +52,7 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.uiUtils)
implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
@ -64,6 +65,7 @@ dependencies {
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings)
implementation(libs.audiowaveform)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View file

@ -28,9 +28,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
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.core.RoomId
@ -49,6 +50,7 @@ class MessagesNode @AssistedInject constructor(
private val analyticsService: AnalyticsService,
private val presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
@ -71,6 +73,9 @@ class MessagesNode @AssistedInject constructor(
lifecycle.subscribe(
onCreate = {
analyticsService.capture(room.toAnalyticsViewRoom())
},
onDestroy = {
mediaPlayer.close()
}
)
}

View file

@ -54,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -331,6 +332,7 @@ class MessagesPresenter @AssistedInject constructor(
type = AttachmentThumbnailType.Location,
)
is TimelineItemPollContent, // TODO Polls: handle reply to
is TimelineItemVoiceContent, // TODO Voice messages: handle reply to
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,
is TimelineItemStateContent,

View file

@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.preferences.api.store.PreferencesStore
@ -131,6 +132,23 @@ class ActionListPresenter @Inject constructor(
}
}
}
is TimelineItemVoiceContent -> {
buildList {
if (timelineItem.isRemote) {
add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (timelineItem.isMine || userCanRedact) {
add(TimelineItemAction.Redact)
}
}
}
else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server

View file

@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -237,6 +238,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
when (event.content) {
is TimelineItemPollContent, // TODO Polls: handle summary
is TimelineItemVoiceContent, // TODO Voice messages: handle reply summary
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemEncryptedContent,

View file

@ -0,0 +1,192 @@
/*
* 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.mediaplayer
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A media player for Element X.
*/
interface MediaPlayer : AutoCloseable {
/**
* The current state of the player.
*/
val state: StateFlow<State>
/**
* Acquires control of the player and starts playing the given media.
*/
fun acquireControlAndPlay(
uri: String,
mediaId: String,
mimeType: String,
)
/**
* Plays the current media.
*/
fun play()
/**
* Pauses the current media.
*/
fun pause()
/**
* Seeks the current media to the given position.
*/
fun seekTo(positionMs: Long)
/**
* Releases any resources associated with this player.
*/
override fun close()
data class State(
/**
* Whether the player is currently playing.
*/
val isPlaying: Boolean,
/**
* The id of the media which is currently playing.
*
* NB: This is usually the string representation of the [EventId] of the event
* which contains the media.
*/
val mediaId: String?,
/**
* The current position of the player.
*/
val currentPosition: Long,
)
}
/**
* Default implementation of [MediaPlayer] backed by a [SimplePlayer].
*/
@ContributesBinding(RoomScope::class)
@SingleIn(RoomScope::class)
class MediaPlayerImpl @Inject constructor(
private val player: SimplePlayer,
) : MediaPlayer {
private val listener = object : SimplePlayer.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_state.update {
it.copy(
currentPosition = player.currentPosition,
isPlaying = isPlaying,
)
}
if (isPlaying) {
job = scope.launch { updateCurrentPosition() }
} else {
job?.cancel()
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?) {
_state.update {
it.copy(
currentPosition = player.currentPosition,
mediaId = mediaItem?.mediaId,
)
}
}
}
init {
player.addListener(listener)
}
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private var job: Job? = null
private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L))
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()
override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) {
player.clearMediaItems()
player.setMediaItem(
MediaItem.Builder()
.setUri(uri)
.setMediaId(mediaId)
.setMimeType(mimeType)
.build()
)
player.prepare()
player.play()
}
override fun play() {
if (player.playbackState == Player.STATE_ENDED) {
// There's a bug with some ogg files that somehow report to
// have no duration.
// With such files, once playback has ended once, calling
// player.seekTo(0) and then player.play() results in the
// player starting and stopping playing immediately effectively
// playing no sound.
// This is a workaround which will reload the media file.
player.getCurrentMediaItem()?.let {
player.setMediaItem(it)
player.prepare()
player.play()
}
} else {
player.play()
}
}
override fun pause() {
player.pause()
}
override fun seekTo(positionMs: Long) {
player.seekTo(positionMs)
}
override fun close() {
player.release()
}
private suspend fun updateCurrentPosition() {
while (true) {
if (!_state.value.isPlaying) return
delay(100)
_state.update {
it.copy(currentPosition = player.currentPosition)
}
}
}
}

View file

@ -0,0 +1,92 @@
/*
* 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.mediaplayer
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
/**
* A subset of media3 [Player] that only exposes the few methods we need making it easier to mock.
*/
interface SimplePlayer {
fun addListener(listener: Listener)
val currentPosition: Long
val playbackState: Int
fun clearMediaItems()
fun setMediaItem(mediaItem: MediaItem)
fun getCurrentMediaItem(): MediaItem?
fun prepare()
fun play()
fun pause()
fun seekTo(positionMs: Long)
fun release()
interface Listener {
fun onIsPlayingChanged(isPlaying: Boolean)
fun onMediaItemTransition(mediaItem: MediaItem?)
}
}
@ContributesTo(RoomScope::class)
@Module
object SimplePlayerModule {
@Provides
fun simplePlayerProvider(
@ApplicationContext context: Context,
): SimplePlayer = SimplePlayerImpl(ExoPlayer.Builder(context).build())
}
/**
* Default implementation of [SimplePlayer] backed by a media3 [Player].
*/
class SimplePlayerImpl(
private val p: Player
) : SimplePlayer {
override fun addListener(listener: SimplePlayer.Listener) {
p.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) = listener.onIsPlayingChanged(isPlaying)
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = listener.onMediaItemTransition(mediaItem)
})
}
override val currentPosition: Long
get() = p.currentPosition
override val playbackState: Int
get() = p.playbackState
override fun clearMediaItems() = p.clearMediaItems()
override fun setMediaItem(mediaItem: MediaItem) = p.setMediaItem(mediaItem)
override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem
override fun prepare() = p.prepare()
override fun play() = p.play()
override fun pause() = p.pause()
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
override fun release() = p.release()
}

View file

@ -20,6 +20,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -32,6 +34,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.libraries.architecture.Presenter
@Composable
fun TimelineItemEventContentView(
@ -44,6 +49,7 @@ fun TimelineItemEventContentView(
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
@ -100,5 +106,14 @@ fun TimelineItemEventContentView(
eventSink = eventSink,
modifier = modifier,
)
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
extraPadding = extraPadding,
modifier = modifier
)
}
}
}

View file

@ -0,0 +1,238 @@
/*
* 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.timeline.components.event
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
import io.element.android.features.messages.impl.voicemessages.timeline.WaveformProgressIndicator
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemVoiceView(
state: VoiceMessageState,
content: TimelineItemVoiceContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> DisabledPlayButton()
}
}
Spacer(Modifier.width(8.dp))
Text(
text = state.time,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
WaveformProgressIndicator(
modifier = Modifier
.height(34.dp)
.weight(1f),
progress = state.progress,
amplitudes = content.waveform,
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }
)
Spacer(Modifier.width(extraPadding.getDpSize()))
}
}
@Composable
private fun PlayButton(
onClick: (() -> Unit)
) {
IconButton(
drawableRes = R.drawable.play,
contentDescription = stringResource(id = CommonStrings.a11y_play),
onClick = onClick
)
}
@Composable
private fun PauseButton(
onClick: (() -> Unit)
) {
IconButton(
drawableRes = R.drawable.pause,
contentDescription = stringResource(id = CommonStrings.a11y_play),
onClick = onClick
)
}
@Composable
private fun RetryButton(
onClick: (() -> Unit)
) {
IconButton(
drawableRes = R.drawable.retry,
contentDescription = stringResource(id = CommonStrings.action_retry),
onClick = onClick
)
}
@Composable
private fun ProgressButton() {
Button {
CircularProgressIndicator(
modifier = Modifier
.padding(2.dp)
.size(12.dp),
color = ElementTheme.materialColors.primary,
strokeWidth = 1.6.dp,
)
}
}
@Composable
private fun DisabledPlayButton() {
IconButton(
drawableRes = R.drawable.play,
contentDescription = null,
onClick = null,
)
}
@Composable
private fun IconButton(
@DrawableRes drawableRes: Int,
contentDescription: String?,
onClick: (() -> Unit)?,
) {
Button(
onClick = onClick,
) {
Icon(
painter = painterResource(id = drawableRes),
contentDescription = contentDescription,
tint = ElementTheme.materialColors.primary,
modifier = Modifier
.size(16.dp),
)
}
}
@Composable
private fun Button(
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ElementTheme.materialColors.background)
.let {
if (onClick != null) it.clickable(onClick = onClick) else it
},
contentAlignment = Alignment.Center,
) {
content()
}
}
open class TimelineItemVoiceViewParametersProvider : PreviewParameterProvider<TimelineItemVoiceViewParameters> {
private val voiceMessageStateProvider = VoiceMessageStateProvider()
private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider()
override val values: Sequence<TimelineItemVoiceViewParameters>
get() = voiceMessageStateProvider.values.zip(timelineItemVoiceContentProvider.values)
.map { TimelineItemVoiceViewParameters(it.first, it.second) }
}
data class TimelineItemVoiceViewParameters(
val state: VoiceMessageState,
val content: TimelineItemVoiceContent,
)
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewPreview(
@PreviewParameter(TimelineItemVoiceViewParametersProvider::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters,
) = ElementPreview {
TimelineItemVoiceView(
state = timelineItemVoiceViewParameters.state,
content = timelineItemVoiceViewParameters.content,
extraPadding = noExtraPadding,
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersProvider()
Column {
timelineItemVoiceViewParametersProvider.values.forEach {
TimelineItemVoiceView(
state = it.state,
content = it.content,
extraPadding = noExtraPadding,
)
}
}
}

View file

@ -52,7 +52,7 @@ class TimelineItemContentFactory @Inject constructor(
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> {
val senderDisplayName = (eventTimelineItem.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: eventTimelineItem.sender.value
messageFactory.create(itemContent, senderDisplayName)
messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
}
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent)

View file

@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -41,15 +45,18 @@ import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.time.Duration
import javax.inject.Inject
class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
private val featureFlagService: FeatureFlagService,
) {
fun create(content: MessageContent, senderDisplayName: String): TimelineItemEventContent {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
return when (val messageType = content.type) {
is EmoteMessageType -> TimelineItemEmoteContent(
body = "* $senderDisplayName ${messageType.body}",
@ -103,7 +110,16 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
}
is AudioMessageType -> TimelineItemAudioContent(
is AudioMessageType -> when {
featureFlagService.isFeatureEnabled(FeatureFlags.VoiceMessages) && messageType.isVoiceMessage -> TimelineItemVoiceContent(
eventId = eventId,
body = messageType.body,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(),
)
else -> TimelineItemAudioContent(
body = messageType.body,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@ -111,6 +127,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
)
}
is FileMessageType -> {
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
TimelineItemFileContent(

View file

@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -58,6 +59,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemAudioContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,

View file

@ -40,6 +40,7 @@ fun TimelineItemEventContent.canBeCopied(): Boolean =
*/
fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
when (this) {
is TimelineItemVoiceContent, // TODO Voice messages: swipe to reply disabled for now to avoid conflict with audio scrubbing.
is TimelineItemRedactedContent,
is TimelineItemStateContent,
is TimelineItemPollContent -> false
@ -58,6 +59,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemImageContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,
is TimelineItemVideoContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 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.timeline.model.event
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.ImmutableList
import java.time.Duration
data class TimelineItemVoiceContent(
val eventId: EventId?,
val body: String,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
val waveform: ImmutableList<Int>,
) : TimelineItemEventContent {
override val type: String = "TimelineItemAudioContent"
}

View file

@ -0,0 +1,58 @@
/*
* 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.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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 kotlinx.collections.immutable.toPersistentList
import java.time.Duration
open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> {
override val values: Sequence<TimelineItemVoiceContent>
get() = sequenceOf(
aTimelineItemVoiceContent(
durationMs = 1,
waveform = listOf(),
),
aTimelineItemVoiceContent(
durationMs = 10_000,
waveform = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
),
aTimelineItemVoiceContent(
durationMs = 1_800_000, // 30 minutes
waveform = List(1024) { it },
),
)
}
fun aTimelineItemVoiceContent(
eventId: String? = "\$anEventId",
body: String = "body doesn't really matter for a voice message",
durationMs: Long = 61_000,
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
mimeType: String = MimeTypes.Ogg,
waveform: List<Int> = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
) = TimelineItemVoiceContent(
eventId = eventId?.let { EventId(it) },
body = body,
duration = Duration.ofMillis(durationMs),
mediaSource = MediaSource(contentUri),
mimeType = mimeType,
waveform = waveform.toPersistentList(),
)

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.CommonStrings
@ -49,6 +50,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> event.content.question
is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)

View file

@ -0,0 +1,123 @@
/*
* 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,22 @@
/*
* 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
sealed interface VoiceMessageEvents {
data object PlayPause : VoiceMessageEvents
data class Seek(val percentage: Float) : VoiceMessageEvents
}

View file

@ -0,0 +1,162 @@
/*
* 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 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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* 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 media uri 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.
*/
fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayer
}
/**
* The current state of this player.
*/
val state: Flow<State>
/**
* Start playing from the beginning acquiring control of the
* underlying [MediaPlayer].
*/
fun acquireControlAndPlay()
/**
* Start playing from the current position.
*/
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 this player is currently playing.
*/
val isPlaying: Boolean,
/**
* Whether this player has control of the underlying [MediaPlayer].
*/
val isMyMedia: Boolean,
/**
* The elapsed time of this player in milliseconds.
*/
val currentPosition: Long,
)
}
/**
* 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.
*/
class VoiceMessagePlayerImpl(
private val mediaPlayer: MediaPlayer,
private val eventId: EventId?,
private val mediaPath: String,
) : VoiceMessagePlayer {
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
class Factory @Inject constructor(
private val mediaPlayer: MediaPlayer,
) : VoiceMessagePlayer.Factory {
override fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayerImpl {
return VoiceMessagePlayerImpl(
mediaPlayer = mediaPlayer,
eventId = eventId,
mediaPath = mediaPath,
)
}
}
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state ->
VoiceMessagePlayer.State(
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
isMyMedia = state.mediaId.isMyTrack(),
currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L
)
}.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 fun pause() {
ifInControl {
mediaPlayer.pause()
}
}
override fun seekTo(positionMs: Long) {
ifInControl {
mediaPlayer.seekTo(positionMs)
}
}
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()
}
}

View file

@ -0,0 +1,151 @@
/*
* 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 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 androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.multibindings.IntoMap
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
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
@Module
@ContributesTo(RoomScope::class)
interface VoiceMessagePresenterModule {
@Binds
@IntoMap
@TimelineItemEventContentKey(TimelineItemVoiceContent::class)
fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): TimelineItemPresenterFactory<*, *>
}
class VoiceMessagePresenter @AssistedInject constructor(
private val mediaLoader: MatrixMediaLoader,
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
voiceMessageCacheFactory: VoiceMessageCache.Factory,
@Assisted private val content: TimelineItemVoiceContent,
) : Presenter<VoiceMessageState> {
@AssistedFactory
fun interface Factory : TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> {
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
)
@Composable
override fun present(): VoiceMessageState {
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 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
else -> VoiceMessageState.Button.Play
}
}
}
val progress by remember {
derivedStateOf { if (playerState.isMyMedia) playerState.currentPosition / content.duration.toMillis().toFloat() else 0f }
}
val time by remember {
derivedStateOf {
val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis()
time.milliseconds.formatShort()
}
}
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()
}
} else {
if (voiceCache.isInCache()) {
player.acquireControlAndPlay()
} else {
scope.launch { downloadCacheAndPlay() }
}
}
}
is VoiceMessageEvents.Seek -> {
player.seekTo((event.percentage * content.duration.toMillis()).toLong())
}
}
}
return VoiceMessageState(
button = button,
progress = progress,
time = time,
eventSink = { eventSink(it) },
)
}
}

View file

@ -0,0 +1,32 @@
/*
* 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
data class VoiceMessageState(
val button: Button,
val progress: Float,
val time: String,
val eventSink: (event: VoiceMessageEvents) -> Unit,
) {
enum class Button {
Play,
Pause,
Downloading,
Retry,
Disabled,
}
}

View file

@ -0,0 +1,55 @@
/*
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
override val values: Sequence<VoiceMessageState>
get() = sequenceOf(
VoiceMessageState(
VoiceMessageState.Button.Downloading,
progress = 0f,
time = "00:00",
eventSink = {},
),
VoiceMessageState(
VoiceMessageState.Button.Retry,
progress = 0.5f,
time = "00:00",
eventSink = {}
),
VoiceMessageState(
VoiceMessageState.Button.Play,
progress = 1f,
time = "00:00",
eventSink = {}
),
VoiceMessageState(
VoiceMessageState.Button.Pause,
progress = 0.2f,
time = "00:00",
eventSink = {}
),
VoiceMessageState(
VoiceMessageState.Button.Disabled,
progress = 0.2f,
time = "00:00",
eventSink = {}
),
)
}

View file

@ -0,0 +1,96 @@
/*
* 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 androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
import com.linc.audiowaveform.AudioWaveform
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
fun WaveformProgressIndicator(
progress: Float,
amplitudes: ImmutableList<Int>,
modifier: Modifier = Modifier,
onSeek: (progress: Float) -> Unit = {},
) {
var seekProgress: Float? by remember { mutableStateOf(null) }
val scaledAmplitudes = remember(amplitudes) { amplitudes.scaleAmplitudes() }
AudioWaveform(
modifier = modifier,
waveformBrush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush = SolidColor(ElementTheme.colors.iconSecondary),
onProgressChangeFinished = {
// This is to send just one onSeek callback after the user has finished seeking.
// Otherwise the AudioWaveform library would send multiple callbacks while the user is seeking.
val p = seekProgress!!
seekProgress = null
onSeek(p)
},
spikeWidth = 1.6.dp,
spikeRadius = 0.8.dp,
spikePadding = 3.dp,
progress = seekProgress ?: progress,
amplitudes = scaledAmplitudes,
onProgressChange = { seekProgress = it },
)
}
@PreviewsDayNight
@Composable
internal fun WaveformProgressIndicatorPreview() = ElementPreview {
Column {
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = persistentListOf(),
)
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = persistentListOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
)
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = List(1024) { it }.toPersistentList()
)
}
}
/**
* Scale amplitudes to fit in the waveform view.
*
* It seems amplitudes > 128 are clipped by the waveform library.
* Workaround for https://github.com/lincollincol/compose-audiowaveform/issues/22
*
* TODO Voice messages: Remove this workaround when the waveform library is fixed.
*/
private fun ImmutableList<Int>.scaleAmplitudes(): List<Int> {
val maxAmplitude = if (isEmpty()) 1 else maxOf { it }
val scalingFactor = 128 / maxAmplitude.toFloat()
return map { (it * scalingFactor).toInt() }
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,19C15.45,19 14.979,18.804 14.587,18.413C14.196,18.021 14,17.55 14,17V7C14,6.45 14.196,5.979 14.587,5.588C14.979,5.196 15.45,5 16,5C16.55,5 17.021,5.196 17.413,5.588C17.804,5.979 18,6.45 18,7V17C18,17.55 17.804,18.021 17.413,18.413C17.021,18.804 16.55,19 16,19ZM8,19C7.45,19 6.979,18.804 6.588,18.413C6.196,18.021 6,17.55 6,17V7C6,6.45 6.196,5.979 6.588,5.588C6.979,5.196 7.45,5 8,5C8.55,5 9.021,5.196 9.413,5.588C9.804,5.979 10,6.45 10,7V17C10,17.55 9.804,18.021 9.413,18.413C9.021,18.804 8.55,19 8,19Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.525,18.025C9.192,18.242 8.854,18.254 8.512,18.063C8.171,17.871 8,17.575 8,17.175V6.825C8,6.425 8.171,6.129 8.512,5.938C8.854,5.746 9.192,5.759 9.525,5.975L17.675,11.15C17.975,11.35 18.125,11.634 18.125,12C18.125,12.367 17.975,12.65 17.675,12.85L9.525,18.025Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,20C9.767,20 7.875,19.225 6.325,17.675C4.775,16.125 4,14.233 4,12C4,9.767 4.775,7.875 6.325,6.325C7.875,4.775 9.767,4 12,4C13.15,4 14.25,4.238 15.3,4.713C16.35,5.188 17.25,5.867 18,6.75V5C18,4.717 18.096,4.479 18.288,4.287C18.479,4.096 18.717,4 19,4C19.283,4 19.521,4.096 19.712,4.287C19.904,4.479 20,4.717 20,5V10C20,10.283 19.904,10.521 19.712,10.712C19.521,10.904 19.283,11 19,11H14C13.717,11 13.479,10.904 13.288,10.712C13.096,10.521 13,10.283 13,10C13,9.717 13.096,9.479 13.288,9.288C13.479,9.096 13.717,9 14,9H17.2C16.667,8.067 15.938,7.333 15.012,6.8C14.087,6.267 13.083,6 12,6C10.333,6 8.917,6.583 7.75,7.75C6.583,8.917 6,10.333 6,12C6,13.667 6.583,15.083 7.75,16.25C8.917,17.417 10.333,18 12,18C13.133,18 14.171,17.712 15.113,17.138C16.054,16.563 16.783,15.792 17.3,14.825C17.433,14.592 17.621,14.429 17.862,14.337C18.104,14.246 18.35,14.242 18.6,14.325C18.867,14.408 19.058,14.583 19.175,14.85C19.292,15.117 19.283,15.367 19.15,15.6C18.467,16.933 17.492,18 16.225,18.8C14.958,19.6 13.55,20 12,20Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.tests.testutils.WarmUpRule
@ -458,6 +459,33 @@ class ActionListPresenterTest {
assertThat(successState.displayEmojiReactions).isTrue()
}
}
@Test
fun `present - compute for voice message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = aTimelineItemVoiceContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Redact,
)
)
)
assertThat(successState.displayEmojiReactions).isTrue()
}
}
}
private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {

View file

@ -49,7 +49,11 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
messageFactory = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()),

View file

@ -0,0 +1,71 @@
/*
* 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.mediaplayer
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Fake implementation of [MediaPlayer] for testing purposes.
*/
class FakeMediaPlayer : MediaPlayer {
private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L))
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()
override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) {
_state.update {
it.copy(
isPlaying = true,
mediaId = mediaId,
currentPosition = it.currentPosition + 1000L,
)
}
}
override fun play() {
_state.update {
it.copy(
isPlaying = true,
currentPosition = it.currentPosition + 1000L,
)
}
}
override fun pause() {
_state.update {
it.copy(
isPlaying = false,
)
}
}
override fun seekTo(positionMs: Long) {
_state.update {
it.copy(
currentPosition = positionMs,
)
}
}
override fun close() {
// no-op
}
}

View file

@ -0,0 +1,49 @@
/*
* 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 io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCache
import java.io.File
/**
* A fake implementation of [VoiceMessageCache] for testing purposes.
*/
class FakeVoiceMessageCache : VoiceMessageCache {
private var _cachePath: String = ""
private var _isInCache: Boolean = false
private var _moveToCache: Boolean = false
override val cachePath: String
get() = _cachePath
override fun isInCache(): Boolean = _isInCache
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
}
}

View file

@ -0,0 +1,90 @@
/*
* 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()
}
}

View file

@ -0,0 +1,294 @@
/*
* 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.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
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.aTimelineItemVoiceContent
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer
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.VoiceMessagePresenter
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import kotlinx.coroutines.test.runTest
import org.junit.Test
class VoiceMessagePresenterTest {
private val fakeMediaLoader = FakeMediaLoader()
private val fakeVoiceCache = FakeVoiceMessageCache()
@Test
fun `initial state has proper default values`() = runTest {
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `pressing play with file in cache plays`() = runTest {
fakeVoiceCache.apply {
givenIsInCache(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.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
}
}
@Test
fun `pressing play with file not in cache downloads it but fails`() = runTest {
fakeMediaLoader.apply {
shouldFail = true
}
fakeVoiceCache.apply {
givenIsInCache(false)
givenMoveToCache(true)
}
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 `pressing play with file not in cache downloads it but then caching fails`() = runTest {
fakeMediaLoader.apply {
shouldFail = false
}
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) {
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.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0.5f)
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
fun `content with null eventId shows disabled button`() = runTest {
fakeMediaLoader.apply {
shouldFail = false
}
fakeVoiceCache.apply {
givenIsInCache(false)
givenMoveToCache(true)
}
val content = aTimelineItemVoiceContent(eventId = null)
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `seeking seeks`() = runTest {
fakeVoiceCache.apply {
givenIsInCache(true)
}
val content = aTimelineItemVoiceContent(durationMs = 10_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:10")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.1f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:05")
}
}
}
}
fun createVoiceMessagePresenter(
fakeMediaLoader: FakeMediaLoader,
voiceCacheFake: FakeVoiceMessageCache,
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
) = VoiceMessagePresenter(
mediaLoader = fakeMediaLoader,
voiceMessagePlayerFactory = { eventId, mediaPath -> VoiceMessagePlayerImpl(FakeMediaPlayer(), eventId, mediaPath) },
voiceMessageCacheFactory = { voiceCacheFake },
content = content,
)

View file

@ -166,6 +166,7 @@ maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
opusencoder = "io.element.android:opusencoder:1.1.0"
audiowaveform = "com.github.lincollincol:compose-audiowaveform:1.1.1"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"

View file

@ -35,6 +35,7 @@ dependencyResolutionManagement {
content {
includeModule("com.github.UnifiedPush", "android-connector")
includeModule("com.github.matrix-org", "matrix-analytics-events")
includeModule("com.github.lincollincol", "compose-audiowaveform")
}
}
// To have immediate access to Rust SDK versions

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1baaa9c4b468aee17da82b1fcafc2a27ad92c4f9bc46122a4cf34a45bbee9e04
size 6284

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8121dbdd25121b7584a3986030d1ca3c842cdf68f6f2650192ea4407eb9f0cf9
size 6317

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1b862d5abc71d58664b4ec0e2f4f442f10afec104eb0d6c26cbd937f67a06107
size 5987

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9fa37a41e254e6eeb79892992085a37c80961ef6fe2c0420fbad5522ec406d4
size 6207

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:251c1e0b256fa73d61af9e3e9893ce5f11d3b78b4e539f66bf44a25073608760
size 6245

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42d1dfd1198f52e615ad13ed4ef42e08a06e792b659cde7625b6bc8e3b599cdb
size 5930

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a3f80ebbc586c13eac889707a5ffbb5ce0a633557e9893a9d09912872627bd5
size 9423

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:be4c1a805efff2c4c09100bef211ffecf7f0d75ff305084af4c2a4b0496bb9cb
size 9212

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e387949352779b84674d3dc23760305b49c197342e3f90fa8ca8576debfa3201
size 25382

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:55b5ec0b3483805b0898a39fcc6d9d20adcebdc03a1926eaa52c5f34f6484be6
size 25026

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5c576c7288e434193189c23f75f92b9569c65c878c4dd9e9dc9b3a44af43f792
size 5555

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e742874a48ffbfa5322d01537fbd49cacf7e42b98b87eb76bf1459a14fb82ac2
size 6353

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b14b7052c2110b2f692472ab8b33631486ab863037106d59e39480203c6432b1
size 5375

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4108d9a249b5bb1c0c88dfe54f9df0f5f7b3a5777d6fd5e00d5fd5e41608cf09
size 21917

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c41c75f17e291e0be64551202fac5147a0174da7634c1e3645842402f61ae4d9
size 5111

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ec3da0af91751e2d2e949da2f8cb94f4805e7747ba5403afd736a393d58447f
size 7145

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d8c35c24f0b725c56fc54eb025a4c42c69cf3acbad37cc550b0b950812cb20b
size 10103

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43891e66bae441f53f576c0affae0f36c98399f9bfe1d034f0db6ae218da8e47
size 8233

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7240b9666592f3627bb2e944cc83ab31eface8139c5c98dcc6c1ad9e7ca9b8b6
size 11285

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b1c4557ffd800c0eca4e41e04e1aea9c6cd0ef94b70f1704352fe8070d8fff1
size 7507

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6859208aff5071a73cd5c8f9f5fe4437abd793fb564bb4dfc4a3a590a8262a1e
size 7946

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1587014aa4cef2ade84e55ab0a9209d0f10b8706164d8e8b28933554dce24d59
size 14386

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac00bcc3150b97a628a03996ce273f68bf5c8ac9db014a7039e3510b4b307726
size 5675

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c0a959f003d8eedf2a4b9284f63ea77a608e272d0dd8e564bbbbf3686c88758
size 11572

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3a3ffdc5062cea8c3226df50b0876a3ca02045a19e05974b95e5ac83b813f39
size 11502

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6f5585d6b47d201c1d3d8b1e330b6ebe2bc23811a72f4454add2f563cdeb41f6
size 6193

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05bb4680a0f8afb75b8f32292b54ccc14aac2e868bc122e110c07d4dc2b30915
size 8596

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3e3588c936172c143fb195a9113d293858c8bd3678b24a88ca64dd9f3c2cb7a1
size 8554

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3cb4d5f28b52e87ce74ca667a330b3b706d1373e6a23c9a9f6a82be9a0460adc
size 5494

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c169da272ac92d2f9ada1eb90a3c644c54e6788141b4d0ee07c49a59972f567
size 6455

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8803bbcece24e3c1dd3eed4055fcb8b8aa1a3d3d06feb5c37813499808c7faf0
size 6481

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7f17122c670bd4e9e8f419d40820d29ac75d3f7f0e5d6d1b4e819ddacfe40057
size 5922

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a6c94b36f43c47dea5d2b06b85ec2008b445ee54bffb45be812c3749e7c8fff
size 5527

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8e5ff9c6ba12aee23ad547b71666096f5a184ad94877135587d77e1ed263c99
size 6240

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f91e0c0c857b7e97a31c01a12947843eca7af664d23dec336e30d386575cd955
size 5345

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a3f45d7dffec016578932cfd230bd57a649b2e725943f81c4d157a958826abe
size 20718

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98992d574b00fd9b168541d7826d2290ffb5598ed31aa540ef86eb05b8cad6a0
size 5115

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12cb95c208d0c536200e75e72476f47bd584a039f47a11be86de607cfdca24ff
size 6836

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22011955b67135850138578a5d0c0211541ec1cb2d422487d149c05d2da5537b
size 9513

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43e01f8600b71c4b7d3437a89331cfa2723049b10500b74902b17e8bae82ecaa
size 7984

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c9e94e9e5b42dc50ef2958872a2307bb5411e25ab8bf3e696fcc1948d04a602a
size 10588

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e3e6f73a4ca3e5630d498541e6ba4b62505652e35f8368bda9dbc5cce618456
size 7367

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:517d6b30065d903215ab57446f3639c3876d85b85bff4679ffae4c2132775dda
size 7567

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:945db7d76ce34a2285e8d083f8c9651204af59aa800ffda4587f854676f717b7
size 13529

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31d2f62b114e1b41d3c7683b2c08e7d70931139e87cf76341d03fb488add4838
size 5662

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51155f13d3795198a301788288351c386e4d509e6ebd3ed103e743762e54a426
size 11208

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6199ff6234486bfe7ed87438298086e09e51dcbe4b30a71fa494764f5b3db14c
size 11142

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d1dd0872ea0786c5b9c037f05aef2c43d278e450558cb2f2245d76a8051f4836
size 6089

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:63953e0cca146083474b69eef7ac861391e111c319ca027e8222dc28cdab7032
size 8075

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5d4c973178bf5793e1231abdc1837aecd16bdb227d0b5128a36db8e1d36290c
size 8009

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa2431a475eecdf0980bd7bf237e30d8b2e1b98170f42474d8297286386342c0
size 5433

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0179e7d8b68b2bb130e0c6c8db6adde979c724d293918822e6807e9eef627135
size 6300

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:506c86795d7940d0cddef77d73eb225b31c9204fc19b81078d95eab71f6d9499
size 6301

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc0d2daf9d4bda4005f01c604bdc6688673e9a50d5e92ca1668c481327529282
size 5831

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fbe739da995dca5700bcbb6a944c611e7c0b427d39b5e085345d8a7e93759e7c
size 25201

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:09864a80f5eaf0874dfdde8698b3fb6ef8ad95059f2d955e822f80009f49849f
size 24966

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6293aceaed02886f3eb1d344c70f93772377d0ae09e69e6a382b33f577a26cd8
size 14765

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6293aceaed02886f3eb1d344c70f93772377d0ae09e69e6a382b33f577a26cd8
size 14765

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2bc02697864bb7289bccfdfc137a7768b763c378e2ca057103058ae0d8c94f98
size 13711

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2bc02697864bb7289bccfdfc137a7768b763c378e2ca057103058ae0d8c94f98
size 13711

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc16dd2b27cf281c39932013a904a72bb6b417a07105d3d51b96f4b49a642e25
size 14865

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b93e0fc28c37a90991ff9778f40fc8e9b07da593d21c6bfc73451006ef6d067c
size 14415

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3cea0fa661bd887a3ab291489603248745dac2db8ee0822079b1ecce486f0aa8
size 5972

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b001719d7d3461e6f03205e7bb368e50a2f5479bf82db7fab1b2e40c51c2a6b8
size 7644

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78f3db8332e540f8a55d65dbc95e0b7ae446d4659f9f3b88ea4545b0dda89646
size 5861

Some files were not shown because too many files have changed in this diff Show more