Merge pull request #433 from vector-im/feature/fga/image_loading

Send and receive media
This commit is contained in:
Jorge Martin Espinosa 2023-05-29 20:33:41 +02:00 committed by GitHub
commit 3f2e042aa5
170 changed files with 3591 additions and 679 deletions

View file

@ -39,8 +39,6 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -114,7 +112,7 @@ class ConfigureRoomPresenter @Inject constructor(
createRoomAction: MutableState<Async<RoomId>> createRoomAction: MutableState<Async<RoomId>>
) = launch { ) = launch {
suspend { suspend {
val mxc = config.avatarUri?.let { uploadAvatar(it) } val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
val params = CreateRoomParameters( val params = CreateRoomParameters(
name = config.roomName, name = config.roomName,
topic = config.topic, topic = config.topic,
@ -123,16 +121,16 @@ class ConfigureRoomPresenter @Inject constructor(
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE, visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT, preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId }, invite = config.invites.map { it.userId },
avatar = mxc, avatar = avatarUrl,
) )
matrixClient.createRoom(params).getOrThrow() matrixClient.createRoom(params).getOrThrow()
.also { dataStore.clearCachedData() } .also { dataStore.clearCachedData() }
}.execute(createRoomAction) }.execute(createRoomAction)
} }
private suspend fun uploadAvatar(avatarUri: Uri): String? { private suspend fun uploadAvatar(avatarUri: Uri): String {
val preprocessed = mediaPreProcessor.process(avatarUri, MediaType.Image).getOrThrow() as? MediaUploadInfo.Image val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
val byteArray = preprocessed?.file?.readBytes() val byteArray = preprocessed.file.readBytes()
return byteArray?.let { matrixClient.uploadMedia(MimeTypes.Jpeg, it) }?.getOrThrow() return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow()
} }
} }

View file

@ -18,6 +18,7 @@ plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize")
} }
android { android {
@ -51,6 +52,11 @@ dependencies {
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.accompanist.systemui)
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -18,12 +18,10 @@ package io.element.android.features.messages.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -33,6 +31,6 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
buildContext: BuildContext, buildContext: BuildContext,
callback: MessagesEntryPoint.Callback callback: MessagesEntryPoint.Callback
): Node { ): Node {
return parentNode.createNode<MessagesNode>(buildContext, listOf(callback)) return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(callback))
} }
} }

View file

@ -0,0 +1,143 @@
/*
* 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
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.ImmutableList
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object Messages : NavTarget
@Parcelize
data class MediaViewer(
val title: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val mimeType: String?,
) : NavTarget
@Parcelize
data class AttachmentPreview(val attachment: Attachment) : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Messages -> {
val callback = object : MessagesNode.Callback {
override fun onRoomDetailsClicked() {
callback?.onRoomDetailsClicked()
}
override fun onEventClicked(event: TimelineItem.Event) {
processEventClicked(event)
}
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(
name = navTarget.title,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
mimeType = navTarget.mimeType,
)
createNode<MediaViewerNode>(buildContext, listOf(inputs))
}
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
}
}
}
private fun processEventClicked(event: TimelineItem.Event) {
when (event.content) {
is TimelineItemImageContent -> {
val navTarget = NavTarget.MediaViewer(
title = event.content.body,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.mediaSource,
mimeType = event.content.mimeType
)
backstack.push(navTarget)
}
is TimelineItemVideoContent -> {
val mediaSource = event.content.videoSource
val navTarget = NavTarget.MediaViewer(
title = event.content.body,
mediaSource = mediaSource,
thumbnailSource = event.content.thumbnailSource,
mimeType = event.content.mimeType,
)
backstack.push(navTarget)
}
else -> Unit
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
)
}
}

View file

@ -25,8 +25,10 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MessagesNode @AssistedInject constructor( class MessagesNode @AssistedInject constructor(
@ -35,12 +37,26 @@ class MessagesNode @AssistedInject constructor(
private val presenter: MessagesPresenter, private val presenter: MessagesPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull() private val callback = plugins<Callback>().firstOrNull()
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event)
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
}
private fun onRoomDetailsClicked() { private fun onRoomDetailsClicked() {
callback?.onRoomDetailsClicked() callback?.onRoomDetailsClicked()
} }
private fun onEventClicked(event: TimelineItem.Event) {
callback?.onEventClicked(event)
}
private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
callback?.onPreviewAttachments(attachments)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -48,7 +64,9 @@ class MessagesNode @AssistedInject constructor(
state = state, state = state,
onBackPressed = this::navigateUp, onBackPressed = this::navigateUp,
onRoomDetailsClicked = this::onRoomDetailsClicked, onRoomDetailsClicked = this::onRoomDetailsClicked,
modifier = modifier onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
modifier = modifier,
) )
} }
} }

View file

@ -27,9 +27,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.textcomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.textcomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage

View file

@ -18,8 +18,7 @@ package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.textcomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
@ -33,8 +32,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
get() = sequenceOf( get() = sequenceOf(
aMessagesState(), aMessagesState(),
aMessagesState().copy(hasNetworkConnection = false), aMessagesState().copy(hasNetworkConnection = false),
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.AllMedia)), aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)),
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.Camera)),
) )
} }

View file

@ -37,6 +37,10 @@ import androidx.compose.material.ListItem
import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@ -60,14 +64,16 @@ import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.textcomposer.MessageComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -79,23 +85,27 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun MessagesView( fun MessagesView(
state: MessagesState, state: MessagesState,
onBackPressed: () -> Unit,
onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onRoomDetailsClicked: () -> Unit = {},
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState( val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden, initialValue = ModalBottomSheetValue.Hidden,
) )
val composerState = state.composerState val composerState = state.composerState
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) { val initialBottomSheetState = if (LocalInspectionMode.current && composerState.showAttachmentSourcePicker) {
ModalBottomSheetValue.Expanded ModalBottomSheetValue.Expanded
} else { } else {
ModalBottomSheetValue.Hidden ModalBottomSheetValue.Hidden
@ -103,6 +113,8 @@ fun MessagesView(
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState) val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
BackHandler(enabled = bottomSheetState.isVisible) { BackHandler(enabled = bottomSheetState.isVisible) {
coroutineScope.launch { coroutineScope.launch {
bottomSheetState.hide() bottomSheetState.hide()
@ -129,6 +141,7 @@ fun MessagesView(
fun onMessageClicked(event: TimelineItem.Event) { fun onMessageClicked(event: TimelineItem.Event) {
Timber.v("OnMessageClicked= ${event.id}") Timber.v("OnMessageClicked= ${event.id}")
onEventClicked(event)
} }
fun onMessageLongClicked(event: TimelineItem.Event) { fun onMessageLongClicked(event: TimelineItem.Event) {
@ -149,8 +162,8 @@ fun MessagesView(
state.eventSink(MessagesEvents.HandleAction(action, event)) state.eventSink(MessagesEvents.HandleAction(action, event))
} }
LaunchedEffect(composerState.attachmentSourcePicker) { LaunchedEffect(composerState.showAttachmentSourcePicker) {
if (composerState.attachmentSourcePicker != null) { if (composerState.showAttachmentSourcePicker) {
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View // We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
localView.hideKeyboard() localView.hideKeyboard()
bottomSheetState.show() bottomSheetState.show()
@ -168,8 +181,7 @@ fun MessagesView(
sheetState = bottomSheetState, sheetState = bottomSheetState,
displayHandle = true, displayHandle = true,
sheetContent = { sheetContent = {
MediaPickerMenu( AttachmentSourcePickerMenu(
addAttachmentSourcePicker = composerState.attachmentSourcePicker,
eventSink = composerState.eventSink eventSink = composerState.eventSink
) )
} }
@ -215,6 +227,20 @@ fun MessagesView(
} }
} }
@Composable
private fun AttachmentStateView(
state: AttachmentsState,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit
) {
when (state) {
AttachmentsState.None -> Unit
is AttachmentsState.Previewing -> LaunchedEffect(state) {
onPreviewAttachments(state.attachments)
}
is AttachmentsState.Sending -> ProgressDialog(text = stringResource(id = StringsR.string.common_loading))
}
}
@Composable @Composable
fun MessagesViewContent( fun MessagesViewContent(
state: MessagesState, state: MessagesState,
@ -289,50 +315,33 @@ fun MessagesViewTopBar(
) )
} }
@Composable
internal fun MediaPickerMenu(
addAttachmentSourcePicker: AttachmentSourcePicker?,
eventSink: (MessageComposerEvents) -> Unit,
) {
when (addAttachmentSourcePicker) {
null -> return
AttachmentSourcePicker.AllMedia -> AllMediaSourcePickerMenu(eventSink = eventSink)
AttachmentSourcePicker.Camera -> CameraSourcePickerMenu(eventSink = eventSink)
}
}
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
internal fun AllMediaSourcePickerMenu( internal fun AttachmentSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit, eventSink: (MessageComposerEvents) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier) { Column(modifier) {
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }) { ListItem(
Text(stringResource(R.string.screen_room_attachment_source_gallery)) modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
} icon = { Icon(Icons.Default.Collections, null) },
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }) { text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
Text(stringResource(R.string.screen_room_attachment_source_files)) )
} ListItem(
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera) }) { modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
Text(stringResource(R.string.screen_room_attachment_source_camera)) icon = { Icon(Icons.Default.AttachFile, null) },
} text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
} )
} ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
@OptIn(ExperimentalMaterialApi::class) icon = { Icon(Icons.Default.PhotoCamera, null) },
@Composable text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
internal fun CameraSourcePickerMenu( )
eventSink: (MessageComposerEvents) -> Unit, ListItem(
modifier: Modifier = Modifier, modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
) { icon = { Icon(Icons.Default.Videocam, null) },
Column(modifier) { text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo) }) { )
Text(stringResource(R.string.screen_room_attachment_source_camera_photo))
}
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video) }) {
Text(stringResource(R.string.screen_room_attachment_source_camera_video))
}
} }
} }
@ -348,5 +357,11 @@ internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::cl
@Composable @Composable
private fun ContentToPreview(state: MessagesState) { private fun ContentToPreview(state: MessagesState) {
MessagesView(state) MessagesView(
state = state,
onBackPressed = {},
onRoomDetailsClicked = {},
onEventClicked = {},
onPreviewAttachments = {}
)
} }

View file

@ -0,0 +1,29 @@
/*
* 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.attachments
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.media.local.LocalMedia
import kotlinx.parcelize.Parcelize
@Immutable
sealed interface Attachment : Parcelable {
@Parcelize
data class Media(val localMedia: LocalMedia, val compressIfPossible: Boolean) : Attachment
}

View file

@ -0,0 +1,25 @@
/*
* 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.attachments.preview
import androidx.compose.runtime.Immutable
@Immutable
sealed interface AttachmentsPreviewEvents {
object SendAttachment : AttachmentsPreviewEvents
object ClearSendState : AttachmentsPreviewEvents
}

View file

@ -0,0 +1,57 @@
/*
* 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.attachments.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
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.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class AttachmentsPreviewNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: AttachmentsPreviewPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val attachment: Attachment) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.attachment)
@Composable
override fun View(modifier: Modifier) {
ForcedDarkElementTheme {
val state = presenter.present()
AttachmentsPreviewView(
state = state,
onDismiss = this::navigateUp,
modifier = modifier
)
}
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult
import io.element.android.libraries.mediaupload.api.MediaSender
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment,
private val mediaSender: MediaSender,
) : Presenter<AttachmentsPreviewState> {
@AssistedFactory
interface Factory {
fun create(attachment: Attachment): AttachmentsPreviewPresenter
}
@Composable
override fun present(): AttachmentsPreviewState {
val coroutineScope = rememberCoroutineScope()
val sendActionState = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState)
AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = Async.Uninitialized
}
}
return AttachmentsPreviewState(
attachment = attachment,
sendActionState = sendActionState.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
sendActionState: MutableState<Async<Unit>>,
) = launch {
when (attachment) {
is Attachment.Media -> {
sendMedia(
mediaAttachment = attachment,
sendActionState = sendActionState
)
}
}
}
private suspend fun sendMedia(
mediaAttachment: Attachment.Media,
sendActionState: MutableState<Async<Unit>>,
) {
suspend {
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible)
}.executeResult(sendActionState)
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.attachments.preview
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async
data class AttachmentsPreviewState(
val attachment: Attachment,
val sendActionState: Async<Unit>,
val eventSink: (AttachmentsPreviewEvents) -> Unit
)

View file

@ -0,0 +1,42 @@
/*
* 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.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.mimetype.MimeTypes
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf(
anAttachmentsPreviewState(),
anAttachmentsPreviewState(sendActionState = Async.Loading()),
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())),
)
}
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L),
compressIfPossible = true
),
sendActionState = sendActionState,
eventSink = {}
)

View file

@ -0,0 +1,177 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.ui.strings.R as StringsR
@Composable
fun AttachmentsPreviewView(
state: AttachmentsPreviewState,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
}
fun postClearSendState() {
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
}
if (state.sendActionState is Async.Success) {
LaunchedEffect(state.sendActionState) {
onDismiss()
}
}
Scaffold(modifier) {
Box(
modifier = Modifier.padding(it),
contentAlignment = Alignment.Center
) {
AttachmentPreviewContent(
attachment = state.attachment,
onSendClicked = ::postSendAttachment,
onDismiss = onDismiss
)
}
}
AttachmentSendStateView(
sendActionState = state.sendActionState,
onRetryClicked = ::postSendAttachment,
onRetryDismissed = ::postClearSendState
)
}
@Composable
private fun AttachmentSendStateView(
sendActionState: Async<Unit>,
onRetryDismissed: () -> Unit,
onRetryClicked: () -> Unit
) {
when (sendActionState) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = R.string.common_loading))
}
is Async.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onRetryDismissed,
onRetry = onRetryClicked
)
}
else -> Unit
}
}
@Composable
private fun AttachmentPreviewContent(
attachment: Attachment,
onSendClicked: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(top = 24.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
when (attachment) {
is Attachment.Media -> LocalMediaView(
localMedia = attachment.localMedia
)
}
}
AttachmentsPreviewBottomActions(
onCancelClicked = onDismiss,
onSendClicked = onSendClicked,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 120.dp)
.padding(all = 24.dp)
)
}
}
@Composable
private fun AttachmentsPreviewBottomActions(
onCancelClicked: () -> Unit,
onSendClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onCancelClicked) {
Text(stringResource(id = StringsR.string.action_cancel))
}
TextButton(onClick = onSendClicked) {
Text(stringResource(id = StringsR.string.action_send))
}
}
}
@Preview
@Composable
fun AttachmentsPreviewViewDarkPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: AttachmentsPreviewState) {
AttachmentsPreviewView(
state = state,
onDismiss = {},
)
}

View file

@ -0,0 +1,30 @@
/*
* 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.attachments.preview.error
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.ui.strings.R
fun sendAttachmentError(
throwable: Throwable
): Int {
return if (throwable is MediaPreProcessor.Failure) {
R.string.screen_media_upload_preview_error_failed_processing
} else {
R.string.screen_media_upload_preview_error_failed_sending
}
}

View file

@ -0,0 +1,52 @@
/*
* 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.media.local
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.file.getFileSize
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaFile
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidLocalMediaFactory @Inject constructor(
@ApplicationContext private val context: Context
) : LocalMediaFactory {
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
val uri = mediaFile.path().toUri()
return createFromUri(uri, mimeType)
}
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia {
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri) ?: MimeTypes.OctetStream
val fileName = context.getFileName(uri)
val fileSize = context.getFileSize(uri)
return LocalMedia(
uri = uri,
mimeType = resolvedMimeType,
name = fileName,
size = fileSize
)
}
}

View file

@ -0,0 +1,40 @@
/*
* 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.media.local
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@Immutable
data class LocalMedia(
val uri: Uri,
val mimeType: String,
val name: String?,
val size: Long,
) : Parcelable {
/**
* This tries to convert the uri to a file if applicable, otherwise keep it as uri.
*/
@IgnoredOnParcel val model: Any by lazy {
UriToFileMapper.map(uri) ?: uri
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.media.local
import android.net.Uri
import io.element.android.libraries.matrix.api.media.MediaFile
interface LocalMediaFactory {
/**
* This method will create a [LocalMedia] with the given [MediaFile] and [mimeType].
*/
fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia
/**
* This method will create a [LocalMedia] with the given [uri] and [mimeType]
* If the [mimeType] is null, it'll try to read it from the content.
*/
fun createFromUri(uri: Uri, mimeType: String?): LocalMedia
}

View file

@ -0,0 +1,156 @@
/*
* 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.media.local
import android.annotation.SuppressLint
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
import me.saket.telephoto.zoomable.rememberZoomableState
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
mimeType: String? = localMedia?.mimeType,
onReady: () -> Unit = {},
) {
when {
MimeTypes.isImage(mimeType) -> MediaImageView(
localMedia = localMedia,
onReady = onReady,
modifier = modifier
)
MimeTypes.isVideo(mimeType) -> MediaVideoView(
localMedia = localMedia,
onReady = onReady,
modifier = modifier
)
else -> Unit
}
}
@Composable
private fun MediaImageView(
localMedia: LocalMedia?,
onReady: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = R.drawable.sample_background),
modifier = modifier.fillMaxSize(),
contentDescription = null,
)
} else {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 3f)
)
val zoomableImageState = rememberZoomableImageState(zoomableState)
LaunchedEffect(zoomableImageState.isImageDisplayed) {
if (zoomableImageState.isImageDisplayed) {
onReady()
}
}
ZoomableAsyncImage(
modifier = modifier.fillMaxSize(),
state = zoomableImageState,
model = localMedia?.model,
contentDescription = "Image",
contentScale = ContentScale.Fit,
)
}
}
@UnstableApi
@Composable
fun MediaVideoView(
localMedia: LocalMedia?,
onReady: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
onReady()
}
}
val exoPlayer = remember {
ExoPlayerWrapper.create(context)
.apply {
addListener(playerListener)
this.prepare()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
AndroidView(
factory = {
PlayerView(context).apply {
player = exoPlayer
setShowPreviousButton(false)
setShowNextButton(false)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
controllerShowTimeoutMs = 3000
}
},
modifier = modifier.fillMaxSize()
)
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> exoPlayer.play()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}

View file

@ -0,0 +1,51 @@
/*
* 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.media.local
import android.content.ContentResolver
import android.net.Uri
import io.element.android.libraries.androidutils.uri.ASSET_FILE_PATH_ROOT
import io.element.android.libraries.androidutils.uri.firstPathSegment
import java.io.File
/**
* Tries to convert a URI to a File.
* Extracted from Coil [coil.map.FileUriMapper]
*/
object UriToFileMapper {
fun map(data: Uri): File? {
if (!isApplicable(data)) return null
return if (data.scheme == ContentResolver.SCHEME_FILE) {
data.path?.let(::File)
} else {
// If the scheme is not "file", it's null, representing a literal path on disk.
// Assume the entire input, regardless of any reserved characters, is valid.
File(data.toString())
}
}
private fun isApplicable(data: Uri): Boolean {
return !isAssetUri(data) &&
data.scheme.let { it == null || it == ContentResolver.SCHEME_FILE } &&
data.path.orEmpty().startsWith('/') && data.firstPathSegment != null
}
private fun isAssetUri(uri: Uri): Boolean {
return uri.scheme == ContentResolver.SCHEME_FILE && uri.firstPathSegment == ASSET_FILE_PATH_ROOT
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.local.exoplayer
import android.content.Context
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
/**
* Wrapper around ExoPlayer to disable some commands.
* Necessary to hide the settings wheels from the player.
*/
@UnstableApi
class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer {
override fun isCommandAvailable(command: Int): Boolean {
return availableCommands.contains(command)
}
override fun getAvailableCommands(): Player.Commands {
return exoPlayer.availableCommands
.buildUpon()
.remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)
.build()
}
companion object {
fun create(context: Context): ExoPlayer {
return ExoPlayerWrapper(
ExoPlayer.Builder(context).build()
)
}
}
}

View file

@ -14,11 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.mediaupload.api package io.element.android.features.messages.impl.media.viewer
sealed interface MediaType { sealed interface MediaViewerEvents {
object Image : MediaType object RetryLoading : MediaViewerEvents
object Video : MediaType object ClearLoadingError : MediaViewerEvents
object Audio : MediaType
object File : MediaType
} }

View file

@ -0,0 +1,61 @@
/*
* 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.media.viewer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaSource
@ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val name: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val mimeType: String?
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs)
@Composable
override fun View(modifier: Modifier) {
ForcedDarkElementTheme {
val state = presenter.present()
MediaViewerView(
state = state,
modifier = modifier
)
}
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.viewer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
) : Presenter<MediaViewerState> {
@AssistedFactory
interface Factory {
fun create(inputs: MediaViewerNode.Inputs): MediaViewerPresenter
}
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
var loadMediaTrigger by remember { mutableStateOf(0) }
val mediaFile: MutableState<MediaFile?> = remember {
mutableStateOf(null)
}
val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized)
}
DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia)
onDispose {
mediaFile.value?.close()
}
}
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
}
}
return MediaViewerState(
name = inputs.name,
mimeType = inputs.mimeType,
thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
localMedia.value = Async.Loading()
mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType)
.onSuccess {
mediaFile.value = it
}.mapCatching {
localMediaFactory.createFromMediaFile(it, inputs.mimeType)
}.onSuccess {
localMedia.value = Async.Success(it)
}.onFailure {
localMedia.value = Async.Failure(it)
}
}
}

View file

@ -0,0 +1,29 @@
/*
* 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.media.viewer
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.media.MediaSource
data class MediaViewerState(
val name: String,
val mimeType: String?,
val thumbnailSource: MediaSource?,
val downloadedMedia: Async<LocalMedia>,
val eventSink: (MediaViewerEvents) -> Unit,
)

View file

@ -0,0 +1,53 @@
/*
* 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.media.viewer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.media3.common.MimeTypes
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
aMediaViewerState(),
aMediaViewerState(Async.Loading()),
aMediaViewerState(Async.Failure(IllegalStateException())),
aMediaViewerState(
Async.Success(
LocalMedia(
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L
)
),
),
aMediaViewerState(
Async.Success(
LocalMedia(
Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L
)
),
)
)
}
fun aMediaViewerState(downloadedMedia: Async<LocalMedia> = Async.Uninitialized) = MediaViewerState(
name = "A media",
mimeType = MimeTypes.IMAGE_JPEG,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
) {}

View file

@ -0,0 +1,179 @@
/*
* 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.media.viewer
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil.compose.AsyncImage
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.coroutines.delay
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun MediaViewerView(
state: MediaViewerState,
modifier: Modifier = Modifier,
) {
fun onRetry() {
state.eventSink(MediaViewerEvents.RetryLoading)
}
fun onDismissError() {
state.eventSink(MediaViewerEvents.ClearLoadingError)
}
var showProgress by remember {
mutableStateOf(false)
}
// Trick to avoid showing progress indicator if the media is already on disk.
// When sdk will expose download progress we'll be able to remove this.
LaunchedEffect(state.downloadedMedia) {
showProgress = false
delay(100)
if (state.downloadedMedia.isLoading()) {
showProgress = true
}
}
var showThumbnail by remember {
mutableStateOf(true)
}
fun onMediaReady() {
showThumbnail = false
}
Scaffold(modifier) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
contentAlignment = Alignment.Center
) {
if (state.downloadedMedia is Async.Failure) {
ErrorView(
errorMessage = stringResource(id = StringR.string.error_unknown),
onRetry = ::onRetry,
onDismiss = ::onDismissError
)
}
LocalMediaView(
localMedia = state.downloadedMedia.dataOrNull(),
mimeType = state.mimeType,
onReady = ::onMediaReady
)
ThumbnailView(
thumbnailSource = state.thumbnailSource,
showThumbnail = showThumbnail,
showProgress = showProgress,
)
}
}
}
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
showThumbnail: Boolean,
showProgress: Boolean,
) {
AnimatedVisibility(
visible = showThumbnail,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.Content
)
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = mediaRequestData,
alpha = 0.8f,
contentScale = ContentScale.Fit,
contentDescription = null,
)
if (showProgress) {
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
private fun ErrorView(
errorMessage: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
RetryDialog(
modifier = modifier,
content = errorMessage,
onRetry = onRetry,
onDismiss = onDismiss
)
}
@Preview
@Composable
fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: MediaViewerState) {
MediaViewerView(
state = state,
)
}

View file

@ -14,10 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
@Immutable
sealed interface MessageComposerEvents { sealed interface MessageComposerEvents {
object ToggleFullScreenState : MessageComposerEvents object ToggleFullScreenState : MessageComposerEvents
data class SendMessage(val message: String) : MessageComposerEvents data class SendMessage(val message: String) : MessageComposerEvents
@ -28,11 +30,8 @@ sealed interface MessageComposerEvents {
object DismissAttachmentMenu : MessageComposerEvents object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents { sealed interface PickAttachmentSource : MessageComposerEvents {
object FromGallery : PickAttachmentSource object FromGallery : PickAttachmentSource
object FromCamera : PickAttachmentSource
object FromFiles : PickAttachmentSource object FromFiles : PickAttachmentSource
} object PhotoFromCamera : PickAttachmentSource
sealed interface PickCameraAttachmentSource : MessageComposerEvents { object VideoFromCamera : PickAttachmentSource
object Photo : PickCameraAttachmentSource
object Video : PickCameraAttachmentSource
} }
} }

View file

@ -14,8 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.messagecomposer
import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -26,12 +27,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ -40,15 +43,13 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
class MessageComposerPresenter @Inject constructor( class MessageComposerPresenter @Inject constructor(
@ -56,44 +57,32 @@ class MessageComposerPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val mediaPreProcessor: MediaPreProcessor, private val localMediaFactory: LocalMediaFactory,
private val mediaSender: MediaSender,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
override fun present(): MessageComposerState { override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType -> val attachmentsState = remember {
if (uri == null) return@registerGalleryPicker mutableStateOf<AttachmentsState>(AttachmentsState.None)
Timber.d("Media picked from $uri")
// We don't know which type of media was retrieved, so we need this check
val mediaType = when {
mimeType.isMimeTypeImage() -> MediaType.Image
mimeType.isMimeTypeVideo() -> MediaType.Video
else -> error("MimeType must be either image/* or video/*")
}
appCoroutineScope.sendMedia(uri, mediaType)
})
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
if (uri == null) return@registerFilePicker
Timber.d("File picked from $uri")
appCoroutineScope.sendMedia(uri, MediaType.File)
} }
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(attachmentsState, uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
handlePickedMedia(attachmentsState, uri, compressIfPossible = false)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
if (uri == null) return@registerCameraPhotoPicker handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
Timber.d("Photo saved at $uri")
appCoroutineScope.sendMedia(uri, MediaType.Image, deleteOriginal = true)
} }
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
if (uri == null) return@registerCameraVideoPicker handlePickedMedia(attachmentsState, uri, MimeTypes.VIDEO_MP4)
Timber.d("Video saved at $uri")
appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true)
} }
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
@ -104,7 +93,7 @@ class MessageComposerPresenter @Inject constructor(
mutableStateOf(MessageComposerMode.Normal("")) mutableStateOf(MessageComposerMode.Normal(""))
} }
var attachmentSourcePicker: AttachmentSourcePicker? by remember { mutableStateOf(null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
LaunchedEffect(composerMode.value) { LaunchedEffect(composerMode.value) {
when (val modeValue = composerMode.value) { when (val modeValue = composerMode.value) {
@ -113,6 +102,13 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState)
else -> Unit
}
}
fun handleEvents(event: MessageComposerEvents) { fun handleEvents(event: MessageComposerEvents) {
when (event) { when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
@ -124,27 +120,24 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text) is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
MessageComposerEvents.AddAttachment -> localCoroutineScope.ifMediaPickersEnabled { MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled {
attachmentSourcePicker = AttachmentSourcePicker.AllMedia showAttachmentSourcePicker = true
} }
MessageComposerEvents.DismissAttachmentMenu -> attachmentSourcePicker = null MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.ifMediaPickersEnabled { MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled {
attachmentSourcePicker = null showAttachmentSourcePicker = false
galleryMediaPicker.launch() galleryMediaPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.ifMediaPickersEnabled { MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled {
attachmentSourcePicker = null showAttachmentSourcePicker = false
filesPicker.launch() filesPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.FromCamera -> localCoroutineScope.ifMediaPickersEnabled { MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
attachmentSourcePicker = AttachmentSourcePicker.Camera showAttachmentSourcePicker = false
}
MessageComposerEvents.PickCameraAttachmentSource.Photo -> localCoroutineScope.ifMediaPickersEnabled {
attachmentSourcePicker = null
cameraPhotoPicker.launch() cameraPhotoPicker.launch()
} }
MessageComposerEvents.PickCameraAttachmentSource.Video -> localCoroutineScope.ifMediaPickersEnabled { MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
attachmentSourcePicker = null showAttachmentSourcePicker = false
cameraVideoPicker.launch() cameraVideoPicker.launch()
} }
} }
@ -154,12 +147,13 @@ class MessageComposerPresenter @Inject constructor(
text = text.value, text = text.value,
isFullScreen = isFullScreen.value, isFullScreen = isFullScreen.value,
mode = composerMode.value, mode = composerMode.value,
attachmentSourcePicker = attachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,
attachmentsState = attachmentsState.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.ifMediaPickersEnabled(action: suspend () -> Unit) = launch { private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) { if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
action() action()
} }
@ -190,44 +184,59 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
private fun CoroutineScope.sendMedia( private fun CoroutineScope.sendAttachment(
uri: Uri, attachment: Attachment,
mediaType: MediaType, attachmentState: MutableState<AttachmentsState>,
deleteOriginal: Boolean = false
) = launch { ) = launch {
runCatching { when (attachment) {
val info = handleMediaPreProcessing(uri, mediaType, deleteOriginal).getOrNull() ?: return@runCatching is Attachment.Media -> {
when (info) { sendMedia(
is MediaUploadInfo.Image -> { uri = attachment.localMedia.uri,
room.sendImage(info.file, info.thumbnailInfo.file, info.info) mimeType = attachment.localMedia.mimeType,
} attachmentState = attachmentState
)
is MediaUploadInfo.Video -> { }
room.sendVideo(info.file, info.thumbnailInfo.file, info.info)
}
is MediaUploadInfo.AnyFile -> {
room.sendFile(info.file, info.info)
}
else -> error("Unexpected MediaUploadInfo format: $info")
}.getOrThrow()
}.onFailure {
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_sending))
Timber.e(it, "Couldn't upload media")
}.onSuccess {
Timber.d("Media uploaded")
} }
} }
private suspend fun handleMediaPreProcessing( @UnstableApi
uri: Uri, private fun handlePickedMedia(
mediaType: MediaType, attachmentsState: MutableState<AttachmentsState>,
deleteOriginal: Boolean, uri: Uri?,
): Result<MediaUploadInfo> { mimeType: String? = null,
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal) compressIfPossible: Boolean = true,
Timber.d("Pre-processed media result: $result") ) {
return result.onFailure { if (uri == null) {
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_processing)) attachmentsState.value = AttachmentsState.None
return
} }
val localMedia = localMediaFactory.createFromUri(uri, mimeType)
val mediaAttachment = Attachment.Media(localMedia, compressIfPossible)
val isPreviewable = when {
MimeTypes.isImage(localMedia.mimeType) -> true
MimeTypes.isVideo(localMedia.mimeType) -> true
MimeTypes.isAudio(localMedia.mimeType) -> true
else -> false
}
attachmentsState.value = if (isPreviewable) {
AttachmentsState.Previewing(persistentListOf(mediaAttachment))
} else {
AttachmentsState.Sending(persistentListOf(mediaAttachment))
}
}
private suspend fun sendMedia(
uri: Uri,
mimeType: String,
attachmentState: MutableState<AttachmentsState>,
) {
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false)
.onSuccess {
attachmentState.value = AttachmentsState.None
}.onFailure {
val snackbarMessage = SnackbarMessage(sendAttachmentError(it))
snackbarDispatcher.post(snackbarMessage)
attachmentState.value = AttachmentsState.None
}
} }
} }

View file

@ -14,24 +14,29 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class MessageComposerState( data class MessageComposerState(
val text: StableCharSequence?, val text: StableCharSequence?,
val isFullScreen: Boolean, val isFullScreen: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val attachmentSourcePicker: AttachmentSourcePicker?, val showAttachmentSourcePicker: Boolean,
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit val eventSink: (MessageComposerEvents) -> Unit
) { ) {
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not() val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
} }
sealed interface AttachmentSourcePicker { @Immutable
object AllMedia : AttachmentSourcePicker sealed interface AttachmentsState {
object Camera : AttachmentSourcePicker object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
data class Sending(val attachments: ImmutableList<Attachment>) : AttachmentsState
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
@ -31,6 +31,7 @@ fun aMessageComposerState() = MessageComposerState(
text = StableCharSequence(""), text = StableCharSequence(""),
isFullScreen = false, isFullScreen = false,
mode = MessageComposerMode.Normal(content = ""), mode = MessageComposerMode.Normal(content = ""),
attachmentSourcePicker = null, showAttachmentSourcePicker = false,
attachmentsState = AttachmentsState.None,
eventSink = {} eventSink = {}
) )

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier

View file

@ -32,8 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlin.random.Random import kotlin.random.Random
fun aTimelineState() = TimelineState( fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
timelineItems = persistentListOf(), timelineItems = timelineItems,
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true), paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
highlightedEventId = null, highlightedEventId = null,
eventSink = {} eventSink = {}

View file

@ -17,7 +17,7 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
@ -33,14 +33,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -53,7 +54,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -61,6 +61,7 @@ import androidx.compose.ui.zIndex
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.MessageEventBubble import io.element.android.features.messages.impl.timeline.components.MessageEventBubble
import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer import io.element.android.features.messages.impl.timeline.components.MessageStateEventContainer
import io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView
import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView import io.element.android.features.messages.impl.timeline.components.TimelineItemReactionsView
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
@ -70,25 +71,23 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.libraries.core.bool.orFalse import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun TimelineView( fun TimelineView(
@ -234,6 +233,7 @@ fun TimelineItemEventRow(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val (parentAlignment, contentAlignment) = if (event.isMine) { val (parentAlignment, contentAlignment) = if (event.isMine) {
Pair(Alignment.CenterEnd, Alignment.End) Pair(Alignment.CenterEnd, Alignment.End)
} else { } else {
@ -257,12 +257,13 @@ fun TimelineItemEventRow(
Modifier.zIndex(1f) Modifier.zIndex(1f)
) )
} }
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
)
MessageEventBubble( MessageEventBubble(
state = BubbleState( state = bubbleState,
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
),
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
@ -270,21 +271,12 @@ fun TimelineItemEventRow(
.zIndex(-1f) .zIndex(-1f)
.widthIn(max = 320.dp) .widthIn(max = 320.dp)
) { ) {
Column { MessageEventBubbleContent(
val contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 6.dp) event = event,
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier) interactionSource = interactionSource,
TimestampView( onMessageClick = onClick,
formattedTime = event.sentTime, onMessageLongClick = onLongClick
hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed, )
isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse(),
onClick = {
// TODO trigger either resending the message or opening the message edition history. This will be implemented later
},
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.align(Alignment.End),
)
}
} }
TimelineItemReactionsView( TimelineItemReactionsView(
reactionsState = event.reactionsState, reactionsState = event.reactionsState,
@ -329,44 +321,66 @@ fun TimelineItemStateEventRow(
.zIndex(-1f) .zIndex(-1f)
.widthIn(max = 320.dp) .widthIn(max = 320.dp)
) { ) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = contentModifier modifier = Modifier.defaultTimelineContentPadding()
) )
} }
} }
} }
@Composable @Composable
private fun TimestampView( fun MessageEventBubbleContent(
formattedTime: String, event: TimelineItem.Event,
isMessageEdited: Boolean, interactionSource: MutableInteractionSource,
hasMessageSendingFailed: Boolean, onMessageClick: () -> Unit,
onClick: () -> Unit, onMessageLongClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null val showTimestampWithOverlay = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
Row(modifier = modifier.clickable(onClick = onClick)){
if (isMessageEdited) { @Composable
Text( fun ContentView(
stringResource(StringR.string.common_edited_suffix), modifier: Modifier = Modifier
style = ElementTextStyles.Regular.caption2, ) {
color = tint ?: MaterialTheme.colorScheme.secondary, TimelineItemEventContentView(
) content = event.content,
Spacer(modifier = Modifier.width(4.dp)) interactionSource = interactionSource,
} onClick = onMessageClick,
Text( onLongClick = onMessageLongClick,
formattedTime, modifier = modifier,
style = ElementTextStyles.Regular.caption1,
color = tint ?: MaterialTheme.colorScheme.secondary,
) )
if (hasMessageSendingFailed && tint != null) { }
Spacer(modifier = Modifier.width(2.dp))
Icon(imageVector = Icons.Default.Error, contentDescription = "Error sending message", tint = tint, modifier = Modifier.size(15.dp, 18.dp)) if (showTimestampWithOverlay) {
Box(modifier.wrapContentSize()) {
ContentView()
Box(
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 4.dp)
.background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp))
.align(Alignment.BottomEnd)
) {
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
} else {
Column {
ContentView(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp))
TimelineEventTimestampView(
event = event,
onClick = onMessageClick,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 2.dp)
)
} }
} }
} }
@ -467,8 +481,6 @@ fun TimelineViewDarkPreview(
private fun ContentToPreview(content: TimelineItemEventContent) { private fun ContentToPreview(content: TimelineItemEventContent) {
val timelineItems = aTimelineItemList(content) val timelineItems = aTimelineItemList(content)
TimelineView( TimelineView(
state = aTimelineState().copy( state = aTimelineState(timelineItems)
timelineItems = timelineItems,
)
) )
} }

View file

@ -0,0 +1,70 @@
/*
* 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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.ui.strings.R
@Composable
fun TimelineEventTimestampView(
event: TimelineItem.Event,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val formattedTime = event.sentTime
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
Row(modifier = modifier.clickable(onClick = onClick)) {
if (isMessageEdited) {
Text(
stringResource(R.string.common_edited_suffix),
style = ElementTextStyles.Regular.caption2,
color = tint ?: MaterialTheme.colorScheme.secondary,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
formattedTime,
style = ElementTextStyles.Regular.caption2,
color = tint ?: MaterialTheme.colorScheme.secondary,
)
if (hasMessageSendingFailed && tint != null) {
Spacer(modifier = Modifier.width(2.dp))
Icon(imageVector = Icons.Default.Error, contentDescription = "Error sending message", tint = tint, modifier = Modifier.size(15.dp, 18.dp))
}
}
}

View file

@ -0,0 +1,100 @@
/*
* 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.blurhash
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import com.vanniktech.blurhash.BlurHash
@Composable
fun BlurHashAsyncImage(
model: Any?,
blurHash: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String? = null,
) {
var isLoading by rememberSaveable(model) { mutableStateOf(true) }
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = model,
contentScale = contentScale,
contentDescription = contentDescription,
onSuccess = { isLoading = false }
)
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
BlurHashImage(
blurHash = blurHash,
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
)
}
}
}
@Composable
fun BlurHashImage(
blurHash: String?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
if (blurHash == null) return
val bitmapState = remember(blurHash) {
mutableStateOf(
// Build a small blurhash image so that it's fast
BlurHash.decode(blurHash, 10, 10)
)
}
DisposableEffect(blurHash) {
onDispose {
bitmapState.value?.recycle()
}
}
bitmapState.value?.let { bitmap ->
Image(
modifier = modifier.fillMaxSize(),
bitmap = bitmap.asImageBitmap(),
contentScale = contentScale,
contentDescription = contentDescription
)
}
}

View file

@ -0,0 +1,46 @@
/*
* 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.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlin.math.min
@Composable
fun TimelineItemAspectRatioBox(
height: Int?,
aspectRatio: Float,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit,
) {
// TODO should probably be moved to an ElementTheme.dimensions
val maxHeight = min(300, height ?: 0)
Box(
modifier = modifier
.heightIn(max = maxHeight.dp)
.aspectRatio(aspectRatio, matchHeightConstraintsFirst = true),
contentAlignment = contentAlignment,
content = content
)
}

View file

@ -17,15 +17,19 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent 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.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent 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.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@Composable @Composable
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
@ -56,6 +60,14 @@ fun TimelineItemEventContentView(
modifier = modifier modifier = modifier
) )
is TimelineItemImageContent -> TimelineItemImageView( is TimelineItemImageContent -> TimelineItemImageView(
content = content,
modifier = modifier,
)
is TimelineItemVideoContent -> TimelineItemVideoView(
content = content,
modifier = modifier
)
is TimelineItemFileContent -> TimelineItemFileView(
content = content, content = content,
modifier = modifier modifier = modifier
) )

View file

@ -0,0 +1,86 @@
/*
* 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.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Attachment
import androidx.compose.material3.MaterialTheme
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemFileView(
content: TimelineItemFileContent,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Attachment,
contentDescription = "OpenFile"
)
}
Text(
text = content.body,
modifier = Modifier.padding(horizontal = 8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Preview
@Composable
internal fun TimelineItemFileViewLightPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) =
ElementPreviewLight { ContentToPreview(content) }
@Preview
@Composable
internal fun TimelineItemFileViewDarkPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) =
ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemFileContent) {
TimelineItemFileView(content)
}

View file

@ -16,56 +16,34 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import coil.compose.AsyncImage import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
import coil.request.ImageRequest
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground import io.element.android.libraries.matrix.ui.media.MediaRequestData
@Composable @Composable
fun TimelineItemImageView( fun TimelineItemImageView(
content: TimelineItemImageContent, content: TimelineItemImageContent,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val widthPercent = if (content.aspectRatio > 1f) { TimelineItemAspectRatioBox(
1f height = content.height,
} else { aspectRatio = content.aspectRatio,
0.7f
}
Box(
modifier = modifier modifier = modifier
.fillMaxWidth(widthPercent)
.aspectRatio(content.aspectRatio),
contentAlignment = Alignment.Center,
) { ) {
val isLoading = rememberSaveable(content.imageMeta) { mutableStateOf(true) } BlurHashAsyncImage(
val context = LocalContext.current model = MediaRequestData(content.mediaSource, MediaRequestData.Kind.Content),
val model = ImageRequest.Builder(context) blurHash = content.blurhash,
.data(content.imageMeta) modifier = Modifier.fillMaxSize(),
.build() contentScale = ContentScale.Fit,
AsyncImage(
model = model,
contentDescription = null,
placeholder = debugPlaceholderBackground(ColorPainter(MaterialTheme.colorScheme.surfaceVariant)),
contentScale = ContentScale.Crop,
onSuccess = { isLoading.value = false },
) )
} }
} }

View file

@ -0,0 +1,83 @@
/*
* 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.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@Composable
fun TimelineItemVideoView(
content: TimelineItemVideoContent,
modifier: Modifier = Modifier,
) {
TimelineItemAspectRatioBox(
height = content.height,
aspectRatio = content.aspectRatio,
modifier = modifier,
contentAlignment = Alignment.Center,
) {
BlurHashAsyncImage(
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.Content),
blurHash = content.blurHash,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
Image(
Icons.Default.PlayArrow,
contentDescription = "Play",
colorFilter = ColorFilter.tint(Color.White),
)
}
}
}
@Preview
@Composable
internal fun TimelineItemVideoViewLightPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) =
ElementPreviewLight { ContentToPreview(content) }
@Preview
@Composable
internal fun TimelineItemVideoViewDarkPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) =
ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemVideoContent) {
TimelineItemVideoView(content)
}

View file

@ -18,17 +18,20 @@ package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent 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.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.util.toHtmlDocument import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import javax.inject.Inject import javax.inject.Inject
class TimelineItemContentMessageFactory @Inject constructor() { class TimelineItemContentMessageFactory @Inject constructor() {
@ -41,23 +44,38 @@ class TimelineItemContentMessageFactory @Inject constructor() {
isEdited = content.isEdited, isEdited = content.isEdited,
) )
is ImageMessageType -> { is ImageMessageType -> {
val height = messageType.info?.height?.toFloat() val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
val width = messageType.info?.width?.toFloat()
val aspectRatio = if (height != null && width != null) {
width / height
} else {
0.7f
}
TimelineItemImageContent( TimelineItemImageContent(
body = messageType.body, body = messageType.body,
imageMeta = MediaResolver.Meta( mediaSource = messageType.source,
url = messageType.url, mimeType = messageType.info?.mimetype,
kind = MediaResolver.Kind.Content
),
blurhash = messageType.info?.blurhash, blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio aspectRatio = aspectRatio
) )
} }
is VideoMessageType -> {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mimeType = messageType.info?.mimetype,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
duration = messageType.info?.duration ?: 0L,
blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio
)
}
is FileMessageType -> TimelineItemFileContent(
body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mimeType = messageType.info?.mimetype,
size = messageType.info?.size,
)
is NoticeMessageType -> TimelineItemNoticeContent( is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body, body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(), htmlDocument = messageType.formatted?.toHtmlDocument(),
@ -71,4 +89,12 @@ class TimelineItemContentMessageFactory @Inject constructor() {
else -> TimelineItemUnknownContent else -> TimelineItemUnknownContent
} }
} }
private fun aspectRatioOf(width: Long?, height: Long?): Float {
return if (height != null && width != null) {
width.toFloat() / height.toFloat()
} else {
0.7f
}
}
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
@ -27,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -66,6 +68,8 @@ class TimelineItemGrouper @Inject constructor() {
is TimelineItemEmoteContent, is TimelineItemEmoteContent,
is TimelineItemNoticeContent, is TimelineItemNoticeContent,
is TimelineItemTextContent, is TimelineItemTextContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
TimelineItemUnknownContent -> false TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent, is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent, is TimelineItemRoomMembershipContent,

View file

@ -24,7 +24,10 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
override val values = sequenceOf( override val values = sequenceOf(
aTimelineItemEmoteContent(), aTimelineItemEmoteContent(),
aTimelineItemEncryptedContent(), aTimelineItemEncryptedContent(),
// TODO MessagesTimelineItemImageContent(), aTimelineItemImageContent(),
aTimelineItemVideoContent(),
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
aTimelineItemNoticeContent(), aTimelineItemNoticeContent(),
aTimelineItemRedactedContent(), aTimelineItemRedactedContent(),
aTimelineItemTextContent(), aTimelineItemTextContent(),

View file

@ -0,0 +1,29 @@
/*
* 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.media.MediaSource
data class TimelineItemFileContent(
val body: String,
val fileSource: MediaSource,
val thumbnailSource: MediaSource?,
val size: Long?,
val mimeType: String?,
) : TimelineItemEventContent {
override val type: String = "TimelineItemFileContent"
}

View file

@ -0,0 +1,38 @@
/*
* 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.media.MediaSource
open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> {
override val values: Sequence<TimelineItemFileContent>
get() = sequenceOf(
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger file name which doesn't fit.pdf"),
)
}
fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent(
body = fileName,
thumbnailSource = MediaSource(url = ""),
fileSource = MediaSource(url = ""),
mimeType = MimeTypes.OctetStream,
size = 100
)

View file

@ -16,13 +16,16 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemImageContent( data class TimelineItemImageContent(
val body: String, val body: String,
val imageMeta: MediaResolver.Meta, val mediaSource: MediaSource,
val mimeType: String?,
val blurhash: String?, val blurhash: String?,
val width: Int?,
val height: Int?,
val aspectRatio: Float val aspectRatio: Float
) : TimelineItemEventContent{ ) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent" override val type: String = "TimelineItemImageContent"
} }

View file

@ -17,7 +17,8 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.media.MediaResolver import androidx.media3.common.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> { open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> {
override val values: Sequence<TimelineItemImageContent> override val values: Sequence<TimelineItemImageContent>
@ -30,7 +31,10 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemImageContent() = TimelineItemImageContent( fun aTimelineItemImageContent() = TimelineItemImageContent(
body = "a body", body = "a body",
imageMeta = MediaResolver.Meta(url = null, kind = MediaResolver.Kind.Content), mediaSource = MediaSource(""),
blurhash = null, mimeType = MimeTypes.IMAGE_JPEG,
aspectRatio = 0.5f, blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
width = null,
height = 300,
aspectRatio = 0.5f
) )

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemVideoContent(
val body: String,
val duration: Long,
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,
val aspectRatio: Float,
val blurHash: String?,
val height: Int?,
val width: Int?,
val mimeType: String?,
) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent"
}

View file

@ -0,0 +1,41 @@
/*
* 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.matrix.api.media.MediaSource
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
override val values: Sequence<TimelineItemVideoContent>
get() = sequenceOf(
aTimelineItemVideoContent(),
aTimelineItemVideoContent().copy(aspectRatio = 1.0f),
aTimelineItemVideoContent().copy(aspectRatio = 1.5f),
)
}
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "a video",
thumbnailSource = MediaSource(url = ""),
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
aspectRatio = 0.5f,
duration = 100,
videoSource = MediaSource(""),
height = 300,
width = 150,
mimeType = null
)

View file

@ -0,0 +1,23 @@
/*
* 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.util
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
fun Modifier.defaultTimelineContentPadding() = padding(horizontal = 12.dp, vertical = 6.dp)

View file

@ -26,9 +26,10 @@ import io.element.android.features.messages.impl.MessagesEvents
import io.element.android.features.messages.impl.MessagesPresenter import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -37,6 +38,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -134,7 +136,8 @@ class MessagesPresenterTest {
room = matrixRoom, room = matrixRoom,
mediaPickerProvider = FakePickerProvider(), mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
mediaPreProcessor = FakeMediaPreProcessor(), localMediaFactory = FakeLocalMediaFactory(),
mediaSender = MediaSender(FakeMediaPreProcessor(),matrixRoom),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
@ -153,4 +156,3 @@ class MessagesPresenterTest {
) )
} }
} }

View file

@ -0,0 +1,95 @@
/*
* 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.attachments
import androidx.media3.common.MimeTypes
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aLocalMedia
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AttachmentsPreviewPresenterTest {
private val mediaPreProcessor = FakeMediaPreProcessor()
@Test
fun `present - send media success scenario`() = runTest {
val room = FakeMatrixRoom()
val presenter = anAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(Async.Uninitialized)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem()
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading<Unit>())
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val successState = awaitItem()
assertThat(successState.sendActionState).isEqualTo(Async.Success(Unit))
assertThat(room.sendMediaCount).isEqualTo(1)
}
}
@Test
fun `present - send media failure scenario`() = runTest {
val room = FakeMatrixRoom()
val failure = MediaPreProcessor.Failure(null)
room.givenSendMediaResult(Result.failure(failure))
val presenter = anAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(Async.Uninitialized)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem()
assertThat(loadingState.sendActionState).isEqualTo(Async.Loading<Unit>())
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(Async.Failure<Unit>(failure))
assertThat(room.sendMediaCount).isEqualTo(0)
failureState.eventSink(AttachmentsPreviewEvents.ClearSendState)
val clearedState = awaitItem()
assertThat(clearedState.sendActionState).isEqualTo(Async.Uninitialized)
}
}
private fun anAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia(mimeType = MimeTypes.IMAGE_JPEG),
room: MatrixRoom = FakeMatrixRoom()
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = Attachment.Media(localMedia, compressIfPossible = false),
mediaSender = MediaSender(mediaPreProcessor, room)
)
}
}

View file

@ -0,0 +1,41 @@
/*
* 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.fixtures
import android.net.Uri
import androidx.media3.common.MimeTypes
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.mockk.mockk
fun aLocalMedia(
uri: Uri = mockk("localMediaUri"),
mimeType: String = MimeTypes.IMAGE_JPEG,
name: String = "a media",
size: Long = 1000,
) = LocalMedia(
uri = uri,
mimeType = mimeType,
name = name,
size = size,
)
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
localMedia = localMedia,
compressIfPossible = compressIfPossible,
)

View file

@ -0,0 +1,37 @@
/*
* 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.media
import android.net.Uri
import io.element.android.features.messages.fixtures.aLocalMedia
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaFile
class FakeLocalMediaFactory : LocalMediaFactory {
var fallbackMimeType: String = MimeTypes.OctetStream
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
return aLocalMedia(mimeType = mimeType ?: fallbackMimeType)
}
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia {
return aLocalMedia(uri, mimeType ?: fallbackMimeType)
}
}

View file

@ -0,0 +1,103 @@
/*
* 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.media.viewer
import androidx.media3.common.MimeTypes
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.media.viewer.MediaViewerPresenter
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val TESTED_MIME_TYPE = MimeTypes.IMAGE_JPEG
private const val TESTED_MEDIA_NAME = "MediaName"
class MediaViewerPresenterTest {
private val localMediaFactory = FakeLocalMediaFactory()
private val mediaLoader = FakeMediaLoader()
@Test
fun `present - download media success scenario`() = runTest {
val presenter = aMediaViewerPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1)
val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successData).isNotNull()
}
}
@Test
fun `present - download media failure then retry with success scenario`() = runTest {
val presenter = aMediaViewerPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
mediaLoader.shouldFail = true
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val failureState = awaitItem()
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
mediaLoader.shouldFail = false
failureState.eventSink(MediaViewerEvents.RetryLoading)
//There is one recomposition because of the retry mechanism
skipItems(1)
val retryLoadingState = awaitItem()
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successData).isNotNull()
}
}
private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs(
name = TESTED_MEDIA_NAME,
mediaSource = aMediaSource(),
mimeType = mimeType,
thumbnailSource = null
),
localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader
)
}
}

View file

@ -23,10 +23,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.textcomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
@ -53,8 +55,6 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
@ -64,13 +64,12 @@ class MessageComposerPresenterTest {
private val pickerProvider = FakePickerProvider().apply { private val pickerProvider = FakePickerProvider().apply {
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
} }
private val featureFlagService = FakeFeatureFlagService().apply { private val featureFlagService = FakeFeatureFlagService(
runBlocking { mapOf(FeatureFlags.ShowMediaUploadingFlow.key to true)
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true) )
}
}
private val mediaPreProcessor = FakeMediaPreProcessor() private val mediaPreProcessor = FakeMediaPreProcessor()
private val snackbarDispatcher = SnackbarDispatcher() private val snackbarDispatcher = SnackbarDispatcher()
private val localMediaFactory = FakeLocalMediaFactory()
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
@ -82,6 +81,8 @@ class MessageComposerPresenterTest {
assertThat(initialState.isFullScreen).isFalse() assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.text).isEqualTo(StableCharSequence("")) assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal("")) assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
assertThat(initialState.isSendButtonVisible).isFalse() assertThat(initialState.isSendButtonVisible).isFalse()
} }
} }
@ -259,22 +260,9 @@ class MessageComposerPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAttachmentSourcePicker).isEqualTo(false)
initialState.eventSink(MessageComposerEvents.AddAttachment) initialState.eventSink(MessageComposerEvents.AddAttachment)
assertThat(awaitItem().showAttachmentSourcePicker).isEqualTo(true)
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.AllMedia)
}
}
@Test
fun `present - Open camera attachments menu`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera)
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.Camera)
} }
} }
@ -289,7 +277,7 @@ class MessageComposerPresenterTest {
skipItems(1) skipItems(1)
initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu) initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
assertThat(awaitItem().attachmentSourcePicker).isNull() assertThat(awaitItem().showAttachmentSourcePicker).isFalse()
} }
} }
@ -308,7 +296,7 @@ class MessageComposerPresenterTest {
mimetype = null, mimetype = null,
size = null, size = null,
thumbnailInfo = null, thumbnailInfo = null,
thumbnailUrl = null, thumbnailSource = null,
blurhash = null, blurhash = null,
), ),
thumbnailInfo = ThumbnailProcessingInfo( thumbnailInfo = ThumbnailProcessingInfo(
@ -329,9 +317,9 @@ class MessageComposerPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
// Wait for the launched upload coroutine to run val previewingState = awaitItem()
runCurrent() assertThat(previewingState.showAttachmentSourcePicker).isFalse()
assertThat(room.sendMediaCount).isEqualTo(1) assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
} }
} }
@ -351,7 +339,7 @@ class MessageComposerPresenterTest {
duration = null, duration = null,
size = null, size = null,
thumbnailInfo = null, thumbnailInfo = null,
thumbnailUrl = null, thumbnailSource = null,
blurhash = null, blurhash = null,
), ),
thumbnailInfo = ThumbnailProcessingInfo( thumbnailInfo = ThumbnailProcessingInfo(
@ -372,22 +360,9 @@ class MessageComposerPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
// Wait for the launched upload coroutine to run val previewingState = awaitItem()
runCurrent() assertThat(previewingState.showAttachmentSourcePicker).isFalse()
assertThat(room.sendMediaCount).isEqualTo(1) assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
}
}
@Test
fun `present - Pick media from gallery fails if returned mimetype is not video or image`() = runTest {
val presenter = createPresenter(this)
pickerProvider.givenMimeType(MimeTypes.Audio)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
assertThat(awaitError()).isInstanceOf(IllegalStateException::class.java)
} }
} }
@ -416,8 +391,11 @@ class MessageComposerPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
// Wait for the launched upload coroutine to run val sendingState = awaitItem()
runCurrent() assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
val sentState = awaitItem()
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
assertThat(room.sendMediaCount).isEqualTo(1) assertThat(room.sendMediaCount).isEqualTo(1)
} }
} }
@ -430,10 +408,11 @@ class MessageComposerPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
// Wait for the launched upload coroutine to run val previewingState = awaitItem()
runCurrent() assertThat(previewingState.showAttachmentSourcePicker).isFalse()
assertThat(room.sendMediaCount).isEqualTo(1) assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@ -445,10 +424,10 @@ class MessageComposerPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
// Wait for the launched upload coroutine to run val previewingState = awaitItem()
runCurrent() assertThat(previewingState.showAttachmentSourcePicker).isFalse()
assertThat(room.sendMediaCount).isEqualTo(1) assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
} }
} }
@ -463,10 +442,11 @@ class MessageComposerPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
snackbarDispatcher.snackbarMessage.test { snackbarDispatcher.snackbarMessage.test {
// Initial value is always null
skipItems(1)
// Assert error message received // Assert error message received
assertThat(awaitItem()).isNotNull() assertThat(awaitItem()).isNotNull()
} }
@ -490,7 +470,13 @@ class MessageComposerPresenterTest {
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
) = MessageComposerPresenter( ) = MessageComposerPresenter(
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher coroutineScope,
room,
pickerProvider,
featureFlagService,
localMediaFactory,
MediaSender(mediaPreProcessor, room),
snackbarDispatcher
) )
} }

View file

@ -17,6 +17,7 @@ recyclerview = "1.3.0"
lifecycle = "2.6.1" lifecycle = "2.6.1"
activity = "1.7.2" activity = "1.7.2"
startup = "1.1.1" startup = "1.1.1"
media3 = "1.0.2"
# Compose # Compose
compose_bom = "2023.05.01" compose_bom = "2023.05.01"
@ -41,6 +42,7 @@ appyx = "1.2.0"
dependencycheck = "8.2.1" dependencycheck = "8.2.1"
stem = "2.3.0" stem = "2.3.0"
sqldelight = "1.5.5" sqldelight = "1.5.5"
telephoto = "0.3.0"
# DI # DI
dagger = "2.46.1" dagger = "2.46.1"
@ -72,6 +74,8 @@ androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx_splash = "androidx.core:core-splashscreen:1.0.1" androidx_splash = "androidx.core:core-splashscreen:1.0.1"
androidx_security_crypto = "androidx.security:security-crypto:1.0.0" androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
@ -141,6 +145,7 @@ unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
gujun_span = "me.gujun.android:span:1.7" gujun_span = "me.gujun.android:span:1.7"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
# Di # Di
inject = "javax.inject:javax.inject:1" inject = "javax.inject:javax.inject:1"

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.file
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.io.File
fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
else -> uri.path?.let(::File)?.name
}
fun Context.getFileSize(uri: Uri): Long {
return when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri)
else -> uri.path?.let(::File)?.length()
} ?: 0
}
private fun Context.getContentFileSize(uri: Uri): Long? = runCatching {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
return@use cursor.getColumnIndexOrThrow(OpenableColumns.SIZE).let(cursor::getLong)
}
}.getOrNull()
private fun Context.getContentFileName(uri: Uri): String? = runCatching {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
}
}.getOrNull()

View file

@ -18,8 +18,6 @@ package io.element.android.libraries.androidutils.file
import android.content.Context import android.content.Context
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@ -37,7 +35,7 @@ fun File.safeDelete() {
) )
} }
suspend fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File = withContext(Dispatchers.IO) { fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
val suffix = extension?.let { ".$extension" } val suffix = extension?.let { ".$extension" }
File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
} }

View file

@ -18,8 +18,12 @@ package io.element.android.libraries.androidutils.uri
import android.net.Uri import android.net.Uri
const val ASSET_FILE_PATH_ROOT = "android_asset"
const val IGNORED_SCHEMA = "ignored" const val IGNORED_SCHEMA = "ignored"
fun Uri.isIgnored() = scheme == IGNORED_SCHEMA fun Uri.isIgnored() = scheme == IGNORED_SCHEMA
fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path") fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path")
val Uri.firstPathSegment: String?
get() = pathSegments.firstOrNull()

View file

@ -25,3 +25,25 @@ inline fun <R, T : R> Result<T>.mapFailure(transform: (exception: Throwable) ->
else -> Result.failure(transform(exception)) else -> Result.failure(transform(exception))
} }
} }
/**
* Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result].
* @return The result of the transform as a [Result].
*/
inline fun <R, T> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> {
return map(transform).fold(
onSuccess = { it },
onFailure = { Result.failure(it) }
)
}
/**
* Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result], catching any exception.
* @return The result of the transform or a caught exception wrapped in a [Result].
*/
inline fun <R, T> Result<T>.flatMapCatching(transform: (T) -> Result<R>): Result<R> {
return mapCatching(transform).fold(
onSuccess = { it },
onFailure = { Result.failure(it) }
)
}

View file

@ -0,0 +1,68 @@
/*
* 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.libraries.core.extensions
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class ResultTests {
@Test
fun testFlatMap() {
val initial = Result.success("initial")
val otherResult = initial.flatMap { Result.success("other") }
val errorResult = initial.flatMap { Result.failure<String>(IllegalStateException("error")) }
assertThat(otherResult.getOrNull()).isEqualTo("other")
assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error")
try {
initial.flatMap<String, String> { error("caught error") }
} catch (e: IllegalStateException) {
assertThat(e.message).isEqualTo("caught error")
}
val initialError = Result.failure<String>(IllegalStateException("initial error"))
val mapErrorToSuccess = initialError.flatMap { Result.success("other") }
val mapErrorToError = initialError.flatMap { Result.failure<String>(IllegalStateException("error")) }
val mapErrorAndCatch: Result<String> = initialError.flatMap { error("error") }
assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error")
}
@Test
fun testFlatMapCatching() {
val initial = Result.success("initial")
val otherResult = initial.flatMapCatching { Result.success("other") }
val errorResult = initial.flatMapCatching { Result.failure<String>(IllegalStateException("error")) }
val caughtExceptionResult: Result<String> = initial.flatMapCatching { error("caught error") }
assertThat(otherResult.getOrNull()).isEqualTo("other")
assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error")
assertThat(caughtExceptionResult.exceptionOrNull()?.message).isEqualTo("caught error")
val initialError = Result.failure<String>(IllegalStateException("initial error"))
val mapErrorToSuccess = initialError.flatMapCatching { Result.success("other") }
val mapErrorToError = initialError.flatMapCatching { Result.failure<String>(IllegalStateException("error")) }
val mapErrorAndCatch: Result<String> = initialError.flatMapCatching { error("error") }
assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error")
assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error")
}
}

View file

@ -75,5 +75,7 @@ val LinkColor = Color(0xFF0086E6)
val TextColorCriticalLight = Color(0xFFD51928) val TextColorCriticalLight = Color(0xFFD51928)
val TextColorCriticalDark = Color(0xfffd3e3c) val TextColorCriticalDark = Color(0xfffd3e3c)
val Gray_400_Light = Color(0xFFE1E6EC) val Compound_Gray_300_Light = Color(0xFFF0F2F5)
val Gray_400_Dark = Color(0xFF26282D) val Compound_Gray_300_Dark = Color(0xFF1D1F24)
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
val Compound_Gray_400_Dark = Color(0xFF26282D)

View file

@ -0,0 +1,40 @@
/*
* 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.libraries.designsystem.modifiers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* This modifier can be use to provide a nice background for Icon or ProgressIndicator.
*/
fun Modifier.roundedBackground(
size: Dp = 48.dp,
color: Color = Color.Black,
alpha: Float = 0.5f,
) = this
.size(size)
.clip(CircleShape)
.background(color = color.copy(alpha = alpha))
.padding(8.dp)

View file

@ -23,10 +23,11 @@ import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.Azure import io.element.android.libraries.designsystem.Azure
import io.element.android.libraries.designsystem.Black_800 import io.element.android.libraries.designsystem.Black_800
import io.element.android.libraries.designsystem.Black_950 import io.element.android.libraries.designsystem.Black_950
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
import io.element.android.libraries.designsystem.DarkGrey import io.element.android.libraries.designsystem.DarkGrey
import io.element.android.libraries.designsystem.Gray_300 import io.element.android.libraries.designsystem.Gray_300
import io.element.android.libraries.designsystem.Gray_400 import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Gray_400_Dark import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
import io.element.android.libraries.designsystem.Gray_450 import io.element.android.libraries.designsystem.Gray_450
import io.element.android.libraries.designsystem.SystemGrey5Dark import io.element.android.libraries.designsystem.SystemGrey5Dark
import io.element.android.libraries.designsystem.SystemGrey6Dark import io.element.android.libraries.designsystem.SystemGrey6Dark
@ -39,7 +40,8 @@ fun elementColorsDark() = ElementColors(
messageHighlightedBackground = Azure, messageHighlightedBackground = Azure,
quaternary = Gray_400, quaternary = Gray_400,
quinary = Gray_450, quinary = Gray_450,
gray400 = Gray_400_Dark, gray300 = Compound_Gray_300_Dark,
gray400 = Compound_Gray_400_Dark,
textActionCritical = TextColorCriticalDark, textActionCritical = TextColorCriticalDark,
isLight = false, isLight = false,
) )

View file

@ -26,7 +26,8 @@ import io.element.android.libraries.designsystem.Gray_100
import io.element.android.libraries.designsystem.Gray_150 import io.element.android.libraries.designsystem.Gray_150
import io.element.android.libraries.designsystem.Gray_200 import io.element.android.libraries.designsystem.Gray_200
import io.element.android.libraries.designsystem.Gray_25 import io.element.android.libraries.designsystem.Gray_25
import io.element.android.libraries.designsystem.Gray_400_Light import io.element.android.libraries.designsystem.Compound_Gray_300_Light
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
import io.element.android.libraries.designsystem.Gray_50 import io.element.android.libraries.designsystem.Gray_50
import io.element.android.libraries.designsystem.SystemGrey5Light import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Light import io.element.android.libraries.designsystem.SystemGrey6Light
@ -39,7 +40,8 @@ fun elementColorsLight() = ElementColors(
messageHighlightedBackground = Azure, messageHighlightedBackground = Azure,
quaternary = Gray_100, quaternary = Gray_100,
quinary = Gray_50, quinary = Gray_50,
gray400 = Gray_400_Light, gray300 = Compound_Gray_300_Light,
gray400 = Compound_Gray_400_Light,
textActionCritical = TextColorCriticalLight, textActionCritical = TextColorCriticalLight,
isLight = true, isLight = true,
) )

View file

@ -29,6 +29,7 @@ class ElementColors(
messageHighlightedBackground: Color, messageHighlightedBackground: Color,
quaternary: Color, quaternary: Color,
quinary: Color, quinary: Color,
gray300: Color,
gray400: Color, gray400: Color,
textActionCritical: Color, textActionCritical: Color,
isLight: Boolean isLight: Boolean
@ -46,6 +47,9 @@ class ElementColors(
var quinary by mutableStateOf(quinary) var quinary by mutableStateOf(quinary)
private set private set
var gray300 by mutableStateOf(gray400)
private set
var gray400 by mutableStateOf(gray400) var gray400 by mutableStateOf(gray400)
private set private set
@ -61,6 +65,7 @@ class ElementColors(
messageHighlightedBackground: Color = this.messageHighlightedBackground, messageHighlightedBackground: Color = this.messageHighlightedBackground,
quaternary: Color = this.quaternary, quaternary: Color = this.quaternary,
quinary: Color = this.quinary, quinary: Color = this.quinary,
gray300: Color = this.gray300,
gray400: Color = this.gray400, gray400: Color = this.gray400,
textActionCritical: Color = this.textActionCritical, textActionCritical: Color = this.textActionCritical,
isLight: Boolean = this.isLight, isLight: Boolean = this.isLight,
@ -70,6 +75,7 @@ class ElementColors(
messageHighlightedBackground = messageHighlightedBackground, messageHighlightedBackground = messageHighlightedBackground,
quaternary = quaternary, quaternary = quaternary,
quinary = quinary, quinary = quinary,
gray300 = gray300,
gray400 = gray400, gray400 = gray400,
textActionCritical = textActionCritical, textActionCritical = textActionCritical,
isLight = isLight, isLight = isLight,
@ -81,6 +87,7 @@ class ElementColors(
messageHighlightedBackground = other.messageHighlightedBackground messageHighlightedBackground = other.messageHighlightedBackground
quaternary = other.quaternary quaternary = other.quaternary
quinary = other.quinary quinary = other.quinary
gray300 = other.gray300
gray400 = other.gray400 gray400 = other.gray400
textActionCritical = other.textActionCritical textActionCritical = other.textActionCritical
isLight = other.isLight isLight = other.isLight

View file

@ -24,12 +24,14 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.google.accompanist.systemuicontroller.SystemUiController
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
/** /**
@ -55,7 +57,6 @@ fun ElementTheme(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
val useDarkIcons = !darkTheme
val currentColor = remember(darkTheme) { val currentColor = remember(darkTheme) {
colors.copy() colors.copy()
}.apply { updateColorsFrom(colors) } }.apply { updateColorsFrom(colors) }
@ -68,13 +69,7 @@ fun ElementTheme(
else -> materialLightColors else -> materialLightColors
} }
SideEffect { SideEffect {
systemUiController.setStatusBarColor( systemUiController.applyTheme(colorScheme = colorScheme, darkTheme = darkTheme)
color = colorScheme.background
)
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
} }
CompositionLocalProvider( CompositionLocalProvider(
LocalColors provides currentColor, LocalColors provides currentColor,
@ -86,3 +81,36 @@ fun ElementTheme(
) )
} }
} }
/**
* Can be used to force a composable in dark theme.
* It will automatically change the system ui colors back to normal when leaving the composition.
*/
@Composable
fun ForcedDarkElementTheme(
content: @Composable () -> Unit,
) {
val systemUiController = rememberSystemUiController()
val colorScheme = MaterialTheme.colorScheme
val wasDarkTheme = !ElementTheme.colors.isLight
DisposableEffect(Unit) {
onDispose {
systemUiController.applyTheme(colorScheme, wasDarkTheme)
}
}
ElementTheme(darkTheme = true, content = content)
}
private fun SystemUiController.applyTheme(
colorScheme: ColorScheme,
darkTheme: Boolean,
) {
val useDarkIcons = !darkTheme
setStatusBarColor(
color = colorScheme.background
)
setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
}

View file

@ -17,7 +17,6 @@
package io.element.android.libraries.designsystem.theme.components package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DividerDefaults import androidx.compose.material3.DividerDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View file

@ -29,8 +29,6 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
@Composable @Composable

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.text.AnnotatedString
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType 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.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
@ -156,10 +157,10 @@ class DefaultRoomLastMessageFormatterTests {
val sharedContentMessagesTypes = arrayOf( val sharedContentMessagesTypes = arrayOf(
TextMessageType(body, null), TextMessageType(body, null),
VideoMessageType(body, "url", null), VideoMessageType(body, MediaSource("url"), null),
AudioMessageType(body, "url", null), AudioMessageType(body, MediaSource("url"), null),
ImageMessageType(body, "url", null), ImageMessageType(body, MediaSource("url"), null),
FileMessageType(body, "url", null), FileMessageType(body, MediaSource("url"), null),
NoticeMessageType(body, null), NoticeMessageType(body, null),
EmoteMessageType(body, null), EmoteMessageType(body, null),
) )

View file

@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -35,6 +35,7 @@ interface MatrixClient : Closeable {
val sessionId: SessionId val sessionId: SessionId
val roomSummaryDataSource: RoomSummaryDataSource val roomSummaryDataSource: RoomSummaryDataSource
val invitesDataSource: RoomSummaryDataSource val invitesDataSource: RoomSummaryDataSource
val mediaLoader: MatrixMediaLoader
fun getRoom(roomId: RoomId): MatrixRoom? fun getRoom(roomId: RoomId): MatrixRoom?
fun findDM(userId: UserId): MatrixRoom? fun findDM(userId: UserId): MatrixRoom?
suspend fun ignoreUser(userId: UserId): Result<Unit> suspend fun ignoreUser(userId: UserId): Result<Unit>
@ -45,24 +46,13 @@ interface MatrixClient : Closeable {
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
fun startSync() fun startSync()
fun stopSync() fun stopSync()
fun mediaResolver(): MediaResolver
fun sessionVerificationService(): SessionVerificationService fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService fun pushersService(): PushersService
fun notificationService(): NotificationService fun notificationService(): NotificationService
suspend fun logout() suspend fun logout()
suspend fun loadUserDisplayName(): Result<String> suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?> suspend fun loadUserAvatarURLString(): Result<String?>
suspend fun loadMediaContent(url: String): Result<ByteArray>
suspend fun loadMediaThumbnail(
url: String,
width: Long,
height: Long
): Result<ByteArray>
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
fun onSlidingSyncUpdate() fun onSlidingSyncUpdate()
fun roomMembershipObserver(): RoomMembershipObserver fun roomMembershipObserver(): RoomMembershipObserver
} }

View file

@ -20,5 +20,5 @@ data class FileInfo(
val mimetype: String?, val mimetype: String?,
val size: Long?, val size: Long?,
val thumbnailInfo: ThumbnailInfo?, val thumbnailInfo: ThumbnailInfo?,
val thumbnailUrl: String? val thumbnailSource: MediaSource?
) )

View file

@ -22,6 +22,6 @@ data class ImageInfo(
val mimetype: String?, val mimetype: String?,
val size: Long?, val size: Long?,
val thumbnailInfo: ThumbnailInfo?, val thumbnailInfo: ThumbnailInfo?,
val thumbnailUrl: String?, val thumbnailSource: MediaSource?,
val blurhash: String? val blurhash: String?
) )

View file

@ -0,0 +1,40 @@
/*
* 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.libraries.matrix.api.media
interface MatrixMediaLoader {
/**
* @param source to fetch the content for.
* @return a [Result] of ByteArray. It contains the binary data for the media.
*/
suspend fun loadMediaContent(source: MediaSource): Result<ByteArray>
/**
* @param source to fetch the data for.
* @param width: the desired width for rescaling the media as thumbnail
* @param height: the desired height for rescaling the media as thumbnail
* @return a [Result] of ByteArray. It contains the binary data for the media.
*/
suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray>
/**
* @param source to fetch the data for.
* @param mimeType: optional mime type
* @return a [Result] of [MediaFile]
*/
suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile>
}

View file

@ -0,0 +1,27 @@
/*
* 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.libraries.matrix.api.media
import java.io.Closeable
/**
* A wrapper around a media file on the disk.
* When closed the file will be removed from the disk.
*/
interface MediaFile : Closeable {
fun path(): String
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.media
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class MediaSource(
/**
* Url of the media.
*/
val url: String,
/**
* This is used to hold data for encrypted media.
*/
val json: String? = null,
) : Parcelable

View file

@ -23,6 +23,6 @@ data class VideoInfo(
val mimetype: String?, val mimetype: String?,
val size: Long?, val size: Long?,
val thumbnailInfo: ThumbnailInfo?, val thumbnailInfo: ThumbnailInfo?,
val thumbnailUrl: String?, val thumbnailSource: MediaSource?,
val blurhash: String? val blurhash: String?
) )

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
sealed interface EventContent sealed interface EventContent
@ -106,25 +107,25 @@ data class EmoteMessageType(
data class ImageMessageType( data class ImageMessageType(
val body: String, val body: String,
val url: String, val source: MediaSource,
val info: ImageInfo? val info: ImageInfo?
) : MessageType ) : MessageType
data class AudioMessageType( data class AudioMessageType(
var body: String, val body: String,
var url: String, val source: MediaSource,
var info: AudioInfo? val info: AudioInfo?
) : MessageType ) : MessageType
data class VideoMessageType( data class VideoMessageType(
val body: String, val body: String,
val url: String, val source: MediaSource,
val info: VideoInfo? val info: VideoInfo?
) : MessageType ) : MessageType
data class FileMessageType( data class FileMessageType(
val body: String, val body: String,
val url: String, val source: MediaSource,
val info: FileInfo? val info: FileInfo?
) : MessageType ) : MessageType

View file

@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -34,7 +34,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.impl.media.RustMediaResolver import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.notification.RustNotificationService
import io.element.android.libraries.matrix.impl.pushers.RustPushersService import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
@ -63,7 +63,6 @@ import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -186,9 +185,12 @@ class RustMatrixClient constructor(
override val invitesDataSource: RoomSummaryDataSource override val invitesDataSource: RoomSummaryDataSource
get() = rustInvitesDataSource get() = rustInvitesDataSource
private val rustMediaLoader = RustMediaLoader(dispatchers, client)
override val mediaLoader: MatrixMediaLoader
get() = rustMediaLoader
private var slidingSyncObserverToken: TaskHandle? = null private var slidingSyncObserverToken: TaskHandle? = null
private val mediaResolver = RustMediaResolver(this)
private val isSyncing = AtomicBoolean(false) private val isSyncing = AtomicBoolean(false)
private val roomMembershipObserver = RoomMembershipObserver() private val roomMembershipObserver = RoomMembershipObserver()
@ -288,8 +290,6 @@ class RustMatrixClient constructor(
} }
} }
override fun mediaResolver(): MediaResolver = mediaResolver
override fun sessionVerificationService(): SessionVerificationService = verificationService override fun sessionVerificationService(): SessionVerificationService = verificationService
override fun pushersService(): PushersService = pushersService override fun pushersService(): PushersService = pushersService
@ -347,34 +347,6 @@ class RustMatrixClient constructor(
} }
} }
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaContent(url: String): Result<ByteArray> =
withContext(dispatchers.io) {
runCatching {
mediaSourceFromUrl(url).use { source ->
client.getMediaContent(source).toUByteArray().toByteArray()
}
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaThumbnail(
url: String,
width: Long,
height: Long
): Result<ByteArray> =
withContext(dispatchers.io) {
runCatching {
mediaSourceFromUrl(url).use { mediaSource ->
client.getMediaThumbnail(
mediaSource = mediaSource,
width = width.toULong(),
height = height.toULong()
).toUByteArray().toByteArray()
}
}
}
@OptIn(ExperimentalUnsignedTypes::class) @OptIn(ExperimentalUnsignedTypes::class)
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(dispatchers.io) { override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(dispatchers.io) {
runCatching { runCatching {

View file

@ -22,16 +22,16 @@ import dagger.Provides
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@Module @Module
@ContributesTo(SessionScope::class) @ContributesTo(SessionScope::class)
object SessionMatrixModule { object SessionMatrixModule {
@Provides @Provides
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService { fun providesSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
return matrixClient.sessionVerificationService() return matrixClient.sessionVerificationService()
} }
@ -40,4 +40,10 @@ object SessionMatrixModule {
fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver { fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver {
return matrixClient.roomMembershipObserver() return matrixClient.roomMembershipObserver()
} }
@Provides
@SingleIn(SessionScope::class)
fun provideMediaLoader(matrixClient: MatrixClient): MatrixMediaLoader {
return matrixClient.mediaLoader
}
} }

View file

@ -17,15 +17,13 @@
package io.element.android.libraries.matrix.impl.media package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import org.matrix.rustcomponents.sdk.FileInfo as RustFileInfo import org.matrix.rustcomponents.sdk.FileInfo as RustFileInfo
import org.matrix.rustcomponents.sdk.ThumbnailInfo as RustThumbnailInfo
fun RustFileInfo.map(): FileInfo = FileInfo( fun RustFileInfo.map(): FileInfo = FileInfo(
mimetype = mimetype, mimetype = mimetype,
size = size?.toLong(), size = size?.toLong(),
thumbnailInfo = thumbnailInfo?.map(), thumbnailInfo = thumbnailInfo?.map(),
thumbnailUrl = thumbnailSource?.useUrl() thumbnailSource = thumbnailSource?.map()
) )
fun FileInfo.map(): RustFileInfo = RustFileInfo( fun FileInfo.map(): RustFileInfo = RustFileInfo(

View file

@ -26,7 +26,7 @@ fun RustImageInfo.map(): ImageInfo = ImageInfo(
mimetype = mimetype, mimetype = mimetype,
size = size?.toLong(), size = size?.toLong(),
thumbnailInfo = thumbnailInfo?.map(), thumbnailInfo = thumbnailInfo?.map(),
thumbnailUrl = thumbnailSource?.useUrl(), thumbnailSource = thumbnailSource?.map(),
blurhash = blurhash blurhash = blurhash
) )

View file

@ -16,7 +16,10 @@
package io.element.android.libraries.matrix.impl.media package io.element.android.libraries.matrix.impl.media
import org.matrix.rustcomponents.sdk.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
fun MediaSource.useUrl(): String = use { it.url() } fun RustMediaSource.map(): MediaSource = use {
MediaSource(it.url(), it.toJson())
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -16,21 +16,16 @@
package io.element.android.libraries.matrix.impl.media package io.element.android.libraries.matrix.impl.media
interface MediaResolver { import io.element.android.libraries.matrix.api.media.MediaFile
import org.matrix.rustcomponents.sdk.MediaFileHandle
sealed interface Kind { class RustMediaFile(private val inner: MediaFileHandle) : MediaFile {
data class Thumbnail(val width: Int, val height: Int) : Kind {
constructor(size: Int) : this(size, size)
}
object Content : Kind override fun path(): String {
return inner.path()
} }
data class Meta( override fun close() {
val url: String?, inner.close()
val kind: Kind }
)
suspend fun resolve(url: String?, kind: Kind): ByteArray?
} }

View file

@ -0,0 +1,84 @@
/*
* 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.libraries.matrix.impl.media
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.MediaSource
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustMediaLoader(
private val dispatchers: CoroutineDispatchers,
private val innerClient: Client
) : MatrixMediaLoader {
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
withContext(dispatchers.io) {
runCatching {
source.toRustMediaSource().use { source ->
innerClient.getMediaContent(source).toUByteArray().toByteArray()
}
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaThumbnail(
source: MediaSource,
width: Long,
height: Long
): Result<ByteArray> =
withContext(dispatchers.io) {
runCatching {
source.toRustMediaSource().use { mediaSource ->
innerClient.getMediaThumbnail(
mediaSource = mediaSource,
width = width.toULong(),
height = height.toULong()
).toUByteArray().toByteArray()
}
}
}
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> =
withContext(dispatchers.io) {
runCatching {
source.toRustMediaSource().use { mediaSource ->
val mediaFile = innerClient.getMediaFile(
mediaSource = mediaSource,
body = null,
mimeType = mimeType ?: "application/octet-stream"
)
RustMediaFile(mediaFile)
}
}
}
private fun MediaSource.toRustMediaSource(): RustMediaSource {
val json = this.json
return if (json != null) {
RustMediaSource.fromJson(json)
} else {
mediaSourceFromUrl(url)
}
}
}

View file

@ -1,35 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.media.MediaResolver
internal class RustMediaResolver(private val client: MatrixClient) : MediaResolver {
override suspend fun resolve(url: String?, kind: MediaResolver.Kind): ByteArray? {
if (url.isNullOrEmpty()) return null
return when (kind) {
is MediaResolver.Kind.Content -> client.loadMediaContent(url)
is MediaResolver.Kind.Thumbnail -> client.loadMediaThumbnail(
url,
kind.width.toLong(),
kind.height.toLong()
)
}.getOrNull()
}
}

View file

@ -26,7 +26,7 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
mimetype = mimetype, mimetype = mimetype,
size = size?.toLong(), size = size?.toLong(),
thumbnailInfo = thumbnailInfo?.map(), thumbnailInfo = thumbnailInfo?.map(),
thumbnailUrl = thumbnailSource?.useUrl(), thumbnailSource = thumbnailSource?.map(),
blurhash = blurhash blurhash = blurhash
) )

View file

@ -222,28 +222,27 @@ class RustMatrixRoom(
} }
} }
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> { override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
return runCatching { runCatching {
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map()) innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
} }
} }
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> { override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
return runCatching { runCatching {
innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map()) innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
} }
} }
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> { override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
return runCatching { runCatching {
innerRoom.sendAudio(file.path, audioInfo.map()) innerRoom.sendAudio(file.path, audioInfo.map())
} }
} }
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> { override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
return runCatching { runCatching {
innerRoom.sendFile(file.path, fileInfo.map()) innerRoom.sendFile(file.path, fileInfo.map())
} }
} }
} }

View file

@ -29,7 +29,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.media.useUrl
import org.matrix.rustcomponents.sdk.Message import org.matrix.rustcomponents.sdk.Message
import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
@ -42,13 +41,13 @@ class EventMessageMapper {
val type = it.msgtype().use { type -> val type = it.msgtype().use { type ->
when (type) { when (type) {
is MessageType.Audio -> { is MessageType.Audio -> {
AudioMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map()) AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
} }
is MessageType.File -> { is MessageType.File -> {
FileMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map()) FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
} }
is MessageType.Image -> { is MessageType.Image -> {
ImageMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map()) ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
} }
is MessageType.Notice -> { is MessageType.Notice -> {
NoticeMessageType(type.content.body, type.content.formatted?.map()) NoticeMessageType(type.content.body, type.content.formatted?.map())
@ -60,7 +59,7 @@ class EventMessageMapper {
EmoteMessageType(type.content.body, type.content.formatted?.map()) EmoteMessageType(type.content.body, type.content.formatted?.map())
} }
is MessageType.Video -> { is MessageType.Video -> {
VideoMessageType(type.content.body, type.content.source.useUrl(), type.content.info?.map()) VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
} }
null -> { null -> {
UnknownMessageType UnknownMessageType

View file

@ -88,7 +88,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
StickerContent( StickerContent(
body = kind.body, body = kind.body,
info = kind.info.map(), info = kind.info.map(),
url = kind.url url = kind.url,
) )
} }
is TimelineItemContentKind.UnableToDecrypt -> { is TimelineItemContentKind.UnableToDecrypt -> {

View file

@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.media.FakeMediaResolver import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -44,6 +44,7 @@ class FakeMatrixClient(
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL), private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
private val pushersService: FakePushersService = FakePushersService(), private val pushersService: FakePushersService = FakePushersService(),
private val notificationService: FakeNotificationService = FakeNotificationService(), private val notificationService: FakeNotificationService = FakeNotificationService(),
@ -100,10 +101,6 @@ class FakeMatrixClient(
override fun stopSync() = Unit override fun stopSync() = Unit
override fun mediaResolver(): MediaResolver {
return FakeMediaResolver()
}
override suspend fun logout() { override suspend fun logout() {
delay(100) delay(100)
logoutFailure?.let { throw it } logoutFailure?.let { throw it }
@ -119,14 +116,6 @@ class FakeMatrixClient(
return userAvatarURLString return userAvatarURLString
} }
override suspend fun loadMediaContent(url: String): Result<ByteArray> {
return Result.success(ByteArray(0))
}
override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result<ByteArray> {
return Result.success(ByteArray(0))
}
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> { override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> {
return uploadMediaResult return uploadMediaResult
} }

View file

@ -52,6 +52,9 @@ val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
const val AN_AVATAR_URL = "mxc://data" const val AN_AVATAR_URL = "mxc://data"
const val A_FAILURE_REASON = "There has been a failure" const val A_FAILURE_REASON = "There has been a failure"
const val FAKE_DELAY_IN_MS = 100L
val A_THROWABLE = Throwable(A_FAILURE_REASON) val A_THROWABLE = Throwable(A_FAILURE_REASON)
val AN_EXCEPTION = Exception(A_FAILURE_REASON) val AN_EXCEPTION = Exception(A_FAILURE_REASON)

View file

@ -16,10 +16,12 @@
package io.element.android.libraries.matrix.test.media package io.element.android.libraries.matrix.test.media
import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.media.MediaFile
class FakeMediaResolver : MediaResolver { class FakeMediaFile(private val path: String) : MediaFile {
override suspend fun resolve(url: String?, kind: MediaResolver.Kind): ByteArray? { override fun path(): String {
return null return path
} }
override fun close() = Unit
} }

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test.media
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.MediaSource
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import kotlinx.coroutines.delay
class FakeMediaLoader : MatrixMediaLoader {
var shouldFail = false
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> {
delay(FAKE_DELAY_IN_MS)
return if (shouldFail) {
Result.failure(RuntimeException())
} else {
return Result.success(ByteArray(0))
}
}
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> {
delay(FAKE_DELAY_IN_MS)
return if (shouldFail) {
Result.failure(RuntimeException())
} else {
return Result.success(ByteArray(0))
}
}
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> {
delay(FAKE_DELAY_IN_MS)
return if (shouldFail) {
Result.failure(RuntimeException())
} else {
return Result.success(FakeMediaFile(""))
}
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.libraries.matrix.test.media
import io.element.android.libraries.matrix.api.media.MediaSource
fun aMediaSource(url: String = "") = MediaSource(
url = url,
json = null
)

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -99,7 +100,7 @@ class FakeMatrixRoom(
} }
override suspend fun sendMessage(message: String): Result<Unit> { override suspend fun sendMessage(message: String): Result<Unit> {
delay(100) delay(FAKE_DELAY_IN_MS)
return Result.success(Unit) return Result.success(Unit)
} }
@ -108,7 +109,7 @@ class FakeMatrixRoom(
override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> { override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> {
editMessageParameter = message editMessageParameter = message
delay(100) delay(FAKE_DELAY_IN_MS)
return Result.success(Unit) return Result.success(Unit)
} }
@ -117,7 +118,7 @@ class FakeMatrixRoom(
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> { override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
replyMessageParameter = message replyMessageParameter = message
delay(100) delay(FAKE_DELAY_IN_MS)
return Result.success(Unit) return Result.success(Unit)
} }
@ -126,7 +127,7 @@ class FakeMatrixRoom(
override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> { override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> {
redactEventEventIdParam = eventId redactEventEventIdParam = eventId
delay(100) delay(FAKE_DELAY_IN_MS)
return Result.success(Unit) return Result.success(Unit)
} }
@ -150,13 +151,20 @@ class FakeMatrixRoom(
return canInviteResult return canInviteResult
} }
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ } override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia()
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ } override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = fakeSendMedia()
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ } override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = fakeSendMedia()
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ } override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = fakeSendMedia()
private suspend fun fakeSendMedia(): Result<Unit> {
delay(FAKE_DELAY_IN_MS)
return sendMediaResult.onSuccess {
sendMediaCount++
}
}
override fun close() = Unit override fun close() = Unit

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