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:
parent
8c7a0c0e0a
commit
6e66c989f4
107 changed files with 2115 additions and 12 deletions
1
changelog.d/2084.feature
Normal file
1
changelog.d/2084.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Receive and play a voice message
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,14 +110,24 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
)
|
||||
}
|
||||
is AudioMessageType -> TimelineItemAudioContent(
|
||||
body = messageType.body,
|
||||
mediaSource = messageType.source,
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
|
||||
)
|
||||
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,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
|
||||
)
|
||||
}
|
||||
is FileMessageType -> {
|
||||
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
TimelineItemFileContent(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
9
features/messages/impl/src/main/res/drawable/pause.xml
Normal file
9
features/messages/impl/src/main/res/drawable/pause.xml
Normal 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>
|
||||
9
features/messages/impl/src/main/res/drawable/play.xml
Normal file
9
features/messages/impl/src/main/res/drawable/play.xml
Normal 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>
|
||||
9
features/messages/impl/src/main/res/drawable/retry.xml
Normal file
9
features/messages/impl/src/main/res/drawable/retry.xml
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1baaa9c4b468aee17da82b1fcafc2a27ad92c4f9bc46122a4cf34a45bbee9e04
|
||||
size 6284
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8121dbdd25121b7584a3986030d1ca3c842cdf68f6f2650192ea4407eb9f0cf9
|
||||
size 6317
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1b862d5abc71d58664b4ec0e2f4f442f10afec104eb0d6c26cbd937f67a06107
|
||||
size 5987
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d9fa37a41e254e6eeb79892992085a37c80961ef6fe2c0420fbad5522ec406d4
|
||||
size 6207
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:251c1e0b256fa73d61af9e3e9893ce5f11d3b78b4e539f66bf44a25073608760
|
||||
size 6245
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:42d1dfd1198f52e615ad13ed4ef42e08a06e792b659cde7625b6bc8e3b599cdb
|
||||
size 5930
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a3f80ebbc586c13eac889707a5ffbb5ce0a633557e9893a9d09912872627bd5
|
||||
size 9423
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:be4c1a805efff2c4c09100bef211ffecf7f0d75ff305084af4c2a4b0496bb9cb
|
||||
size 9212
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e387949352779b84674d3dc23760305b49c197342e3f90fa8ca8576debfa3201
|
||||
size 25382
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:55b5ec0b3483805b0898a39fcc6d9d20adcebdc03a1926eaa52c5f34f6484be6
|
||||
size 25026
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5c576c7288e434193189c23f75f92b9569c65c878c4dd9e9dc9b3a44af43f792
|
||||
size 5555
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e742874a48ffbfa5322d01537fbd49cacf7e42b98b87eb76bf1459a14fb82ac2
|
||||
size 6353
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b14b7052c2110b2f692472ab8b33631486ab863037106d59e39480203c6432b1
|
||||
size 5375
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4108d9a249b5bb1c0c88dfe54f9df0f5f7b3a5777d6fd5e00d5fd5e41608cf09
|
||||
size 21917
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c41c75f17e291e0be64551202fac5147a0174da7634c1e3645842402f61ae4d9
|
||||
size 5111
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4ec3da0af91751e2d2e949da2f8cb94f4805e7747ba5403afd736a393d58447f
|
||||
size 7145
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1d8c35c24f0b725c56fc54eb025a4c42c69cf3acbad37cc550b0b950812cb20b
|
||||
size 10103
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:43891e66bae441f53f576c0affae0f36c98399f9bfe1d034f0db6ae218da8e47
|
||||
size 8233
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7240b9666592f3627bb2e944cc83ab31eface8139c5c98dcc6c1ad9e7ca9b8b6
|
||||
size 11285
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5b1c4557ffd800c0eca4e41e04e1aea9c6cd0ef94b70f1704352fe8070d8fff1
|
||||
size 7507
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6859208aff5071a73cd5c8f9f5fe4437abd793fb564bb4dfc4a3a590a8262a1e
|
||||
size 7946
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1587014aa4cef2ade84e55ab0a9209d0f10b8706164d8e8b28933554dce24d59
|
||||
size 14386
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ac00bcc3150b97a628a03996ce273f68bf5c8ac9db014a7039e3510b4b307726
|
||||
size 5675
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c0a959f003d8eedf2a4b9284f63ea77a608e272d0dd8e564bbbbf3686c88758
|
||||
size 11572
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d3a3ffdc5062cea8c3226df50b0876a3ca02045a19e05974b95e5ac83b813f39
|
||||
size 11502
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6f5585d6b47d201c1d3d8b1e330b6ebe2bc23811a72f4454add2f563cdeb41f6
|
||||
size 6193
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:05bb4680a0f8afb75b8f32292b54ccc14aac2e868bc122e110c07d4dc2b30915
|
||||
size 8596
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3e3588c936172c143fb195a9113d293858c8bd3678b24a88ca64dd9f3c2cb7a1
|
||||
size 8554
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3cb4d5f28b52e87ce74ca667a330b3b706d1373e6a23c9a9f6a82be9a0460adc
|
||||
size 5494
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2c169da272ac92d2f9ada1eb90a3c644c54e6788141b4d0ee07c49a59972f567
|
||||
size 6455
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8803bbcece24e3c1dd3eed4055fcb8b8aa1a3d3d06feb5c37813499808c7faf0
|
||||
size 6481
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7f17122c670bd4e9e8f419d40820d29ac75d3f7f0e5d6d1b4e819ddacfe40057
|
||||
size 5922
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7a6c94b36f43c47dea5d2b06b85ec2008b445ee54bffb45be812c3749e7c8fff
|
||||
size 5527
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d8e5ff9c6ba12aee23ad547b71666096f5a184ad94877135587d77e1ed263c99
|
||||
size 6240
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f91e0c0c857b7e97a31c01a12947843eca7af664d23dec336e30d386575cd955
|
||||
size 5345
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5a3f45d7dffec016578932cfd230bd57a649b2e725943f81c4d157a958826abe
|
||||
size 20718
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:98992d574b00fd9b168541d7826d2290ffb5598ed31aa540ef86eb05b8cad6a0
|
||||
size 5115
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12cb95c208d0c536200e75e72476f47bd584a039f47a11be86de607cfdca24ff
|
||||
size 6836
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:22011955b67135850138578a5d0c0211541ec1cb2d422487d149c05d2da5537b
|
||||
size 9513
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:43e01f8600b71c4b7d3437a89331cfa2723049b10500b74902b17e8bae82ecaa
|
||||
size 7984
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c9e94e9e5b42dc50ef2958872a2307bb5411e25ab8bf3e696fcc1948d04a602a
|
||||
size 10588
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9e3e6f73a4ca3e5630d498541e6ba4b62505652e35f8368bda9dbc5cce618456
|
||||
size 7367
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:517d6b30065d903215ab57446f3639c3876d85b85bff4679ffae4c2132775dda
|
||||
size 7567
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:945db7d76ce34a2285e8d083f8c9651204af59aa800ffda4587f854676f717b7
|
||||
size 13529
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:31d2f62b114e1b41d3c7683b2c08e7d70931139e87cf76341d03fb488add4838
|
||||
size 5662
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:51155f13d3795198a301788288351c386e4d509e6ebd3ed103e743762e54a426
|
||||
size 11208
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6199ff6234486bfe7ed87438298086e09e51dcbe4b30a71fa494764f5b3db14c
|
||||
size 11142
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d1dd0872ea0786c5b9c037f05aef2c43d278e450558cb2f2245d76a8051f4836
|
||||
size 6089
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:63953e0cca146083474b69eef7ac861391e111c319ca027e8222dc28cdab7032
|
||||
size 8075
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d5d4c973178bf5793e1231abdc1837aecd16bdb227d0b5128a36db8e1d36290c
|
||||
size 8009
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aa2431a475eecdf0980bd7bf237e30d8b2e1b98170f42474d8297286386342c0
|
||||
size 5433
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0179e7d8b68b2bb130e0c6c8db6adde979c724d293918822e6807e9eef627135
|
||||
size 6300
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:506c86795d7940d0cddef77d73eb225b31c9204fc19b81078d95eab71f6d9499
|
||||
size 6301
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc0d2daf9d4bda4005f01c604bdc6688673e9a50d5e92ca1668c481327529282
|
||||
size 5831
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fbe739da995dca5700bcbb6a944c611e7c0b427d39b5e085345d8a7e93759e7c
|
||||
size 25201
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:09864a80f5eaf0874dfdde8698b3fb6ef8ad95059f2d955e822f80009f49849f
|
||||
size 24966
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6293aceaed02886f3eb1d344c70f93772377d0ae09e69e6a382b33f577a26cd8
|
||||
size 14765
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6293aceaed02886f3eb1d344c70f93772377d0ae09e69e6a382b33f577a26cd8
|
||||
size 14765
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2bc02697864bb7289bccfdfc137a7768b763c378e2ca057103058ae0d8c94f98
|
||||
size 13711
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2bc02697864bb7289bccfdfc137a7768b763c378e2ca057103058ae0d8c94f98
|
||||
size 13711
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc16dd2b27cf281c39932013a904a72bb6b417a07105d3d51b96f4b49a642e25
|
||||
size 14865
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b93e0fc28c37a90991ff9778f40fc8e9b07da593d21c6bfc73451006ef6d067c
|
||||
size 14415
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3cea0fa661bd887a3ab291489603248745dac2db8ee0822079b1ecce486f0aa8
|
||||
size 5972
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b001719d7d3461e6f03205e7bb368e50a2f5479bf82db7fab1b2e40c51c2a6b8
|
||||
size 7644
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue