Merge pull request #433 from vector-im/feature/fga/image_loading
Send and receive media
This commit is contained in:
commit
09be5f4a6a
170 changed files with 3591 additions and 679 deletions
|
|
@ -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.mediapickers.api.PickerProvider
|
||||
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.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -114,7 +112,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
|||
createRoomAction: MutableState<Async<RoomId>>
|
||||
) = launch {
|
||||
suspend {
|
||||
val mxc = config.avatarUri?.let { uploadAvatar(it) }
|
||||
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
|
||||
val params = CreateRoomParameters(
|
||||
name = config.roomName,
|
||||
topic = config.topic,
|
||||
|
|
@ -123,16 +121,16 @@ class ConfigureRoomPresenter @Inject constructor(
|
|||
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
|
||||
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
|
||||
invite = config.invites.map { it.userId },
|
||||
avatar = mxc,
|
||||
avatar = avatarUrl,
|
||||
)
|
||||
matrixClient.createRoom(params).getOrThrow()
|
||||
.also { dataStore.clearCachedData() }
|
||||
}.execute(createRoomAction)
|
||||
}
|
||||
|
||||
private suspend fun uploadAvatar(avatarUri: Uri): String? {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MediaType.Image).getOrThrow() as? MediaUploadInfo.Image
|
||||
val byteArray = preprocessed?.file?.readBytes()
|
||||
return byteArray?.let { matrixClient.uploadMedia(MimeTypes.Jpeg, it) }?.getOrThrow()
|
||||
private suspend fun uploadAvatar(avatarUri: Uri): String {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
||||
val byteArray = preprocessed.file.readBytes()
|
||||
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ plugins {
|
|||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -51,6 +52,11 @@ dependencies {
|
|||
implementation(libs.accompanist.flowlayout)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
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.coroutines.test)
|
||||
|
|
|
|||
|
|
@ -18,12 +18,10 @@ package io.element.android.features.messages.impl
|
|||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
|
|
@ -33,6 +31,6 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
|||
buildContext: BuildContext,
|
||||
callback: MessagesEntryPoint.Callback
|
||||
): Node {
|
||||
return parentNode.createNode<MessagesNode>(buildContext, listOf(callback))
|
||||
return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(callback))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,8 +25,10 @@ import com.bumble.appyx.core.plugin.plugins
|
|||
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.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class MessagesNode @AssistedInject constructor(
|
||||
|
|
@ -35,12 +37,26 @@ class MessagesNode @AssistedInject constructor(
|
|||
private val presenter: MessagesPresenter,
|
||||
) : 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() {
|
||||
callback?.onRoomDetailsClicked()
|
||||
}
|
||||
|
||||
private fun onEventClicked(event: TimelineItem.Event) {
|
||||
callback?.onEventClicked(event)
|
||||
}
|
||||
|
||||
private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
|
||||
callback?.onPreviewAttachments(attachments)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -48,7 +64,9 @@ class MessagesNode @AssistedInject constructor(
|
|||
state = state,
|
||||
onBackPressed = this::navigateUp,
|
||||
onRoomDetailsClicked = this::onRoomDetailsClicked,
|
||||
modifier = modifier
|
||||
onEventClicked = this::onEventClicked,
|
||||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.textcomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
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.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ package io.element.android.features.messages.impl
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ package io.element.android.features.messages.impl
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
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.textcomposer.aMessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
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.model.event.aTimelineItemTextContent
|
||||
|
|
@ -33,8 +32,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
|||
get() = sequenceOf(
|
||||
aMessagesState(),
|
||||
aMessagesState().copy(hasNetworkConnection = false),
|
||||
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.AllMedia)),
|
||||
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.Camera)),
|
||||
aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ import androidx.compose.material.ListItem
|
|||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.material3.ExperimentalMaterial3Api
|
||||
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.ActionListView
|
||||
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.textcomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
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.TimelineView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||
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.AvatarData
|
||||
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.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import io.element.android.libraries.ui.strings.R as StringsR
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MessagesView(
|
||||
state: MessagesState,
|
||||
onBackPressed: () -> Unit,
|
||||
onRoomDetailsClicked: () -> Unit,
|
||||
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
onRoomDetailsClicked: () -> Unit = {},
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
)
|
||||
val composerState = state.composerState
|
||||
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
|
||||
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.showAttachmentSourcePicker) {
|
||||
ModalBottomSheetValue.Expanded
|
||||
} else {
|
||||
ModalBottomSheetValue.Hidden
|
||||
|
|
@ -103,6 +113,8 @@ fun MessagesView(
|
|||
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||
|
||||
BackHandler(enabled = bottomSheetState.isVisible) {
|
||||
coroutineScope.launch {
|
||||
bottomSheetState.hide()
|
||||
|
|
@ -129,6 +141,7 @@ fun MessagesView(
|
|||
|
||||
fun onMessageClicked(event: TimelineItem.Event) {
|
||||
Timber.v("OnMessageClicked= ${event.id}")
|
||||
onEventClicked(event)
|
||||
}
|
||||
|
||||
fun onMessageLongClicked(event: TimelineItem.Event) {
|
||||
|
|
@ -149,8 +162,8 @@ fun MessagesView(
|
|||
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||
}
|
||||
|
||||
LaunchedEffect(composerState.attachmentSourcePicker) {
|
||||
if (composerState.attachmentSourcePicker != null) {
|
||||
LaunchedEffect(composerState.showAttachmentSourcePicker) {
|
||||
if (composerState.showAttachmentSourcePicker) {
|
||||
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||
localView.hideKeyboard()
|
||||
bottomSheetState.show()
|
||||
|
|
@ -168,8 +181,7 @@ fun MessagesView(
|
|||
sheetState = bottomSheetState,
|
||||
displayHandle = true,
|
||||
sheetContent = {
|
||||
MediaPickerMenu(
|
||||
addAttachmentSourcePicker = composerState.attachmentSourcePicker,
|
||||
AttachmentSourcePickerMenu(
|
||||
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
|
||||
fun MessagesViewContent(
|
||||
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)
|
||||
@Composable
|
||||
internal fun AllMediaSourcePickerMenu(
|
||||
internal fun AttachmentSourcePickerMenu(
|
||||
eventSink: (MessageComposerEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier) {
|
||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }) {
|
||||
Text(stringResource(R.string.screen_room_attachment_source_gallery))
|
||||
}
|
||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }) {
|
||||
Text(stringResource(R.string.screen_room_attachment_source_files))
|
||||
}
|
||||
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera) }) {
|
||||
Text(stringResource(R.string.screen_room_attachment_source_camera))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
internal fun CameraSourcePickerMenu(
|
||||
eventSink: (MessageComposerEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier) {
|
||||
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))
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
|
||||
icon = { Icon(Icons.Default.Collections, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
|
||||
icon = { Icon(Icons.Default.AttachFile, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
|
||||
icon = { Icon(Icons.Default.PhotoCamera, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
|
||||
icon = { Icon(Icons.Default.Videocam, null) },
|
||||
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -348,5 +357,11 @@ internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::cl
|
|||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: MessagesState) {
|
||||
MessagesView(state)
|
||||
MessagesView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
onRoomDetailsClicked = {},
|
||||
onEventClicked = {},
|
||||
onPreviewAttachments = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,11 +14,9 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
package io.element.android.features.messages.impl.media.viewer
|
||||
|
||||
sealed interface MediaType {
|
||||
object Image : MediaType
|
||||
object Video : MediaType
|
||||
object Audio : MediaType
|
||||
object File : MediaType
|
||||
sealed interface MediaViewerEvents {
|
||||
object RetryLoading : MediaViewerEvents
|
||||
object ClearLoadingError : MediaViewerEvents
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -14,10 +14,12 @@
|
|||
* 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
|
||||
|
||||
@Immutable
|
||||
sealed interface MessageComposerEvents {
|
||||
object ToggleFullScreenState : MessageComposerEvents
|
||||
data class SendMessage(val message: String) : MessageComposerEvents
|
||||
|
|
@ -28,11 +30,8 @@ sealed interface MessageComposerEvents {
|
|||
object DismissAttachmentMenu : MessageComposerEvents
|
||||
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||
object FromGallery : PickAttachmentSource
|
||||
object FromCamera : PickAttachmentSource
|
||||
object FromFiles : PickAttachmentSource
|
||||
}
|
||||
sealed interface PickCameraAttachmentSource : MessageComposerEvents {
|
||||
object Photo : PickCameraAttachmentSource
|
||||
object Video : PickCameraAttachmentSource
|
||||
object PhotoFromCamera : PickAttachmentSource
|
||||
object VideoFromCamera : PickAttachmentSource
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,9 @@
|
|||
* 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 androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -26,12 +27,14 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.core.data.StableCharSequence
|
||||
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.SnackbarMessage
|
||||
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.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
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 io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
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)
|
||||
class MessageComposerPresenter @Inject constructor(
|
||||
|
|
@ -56,44 +57,32 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private val room: MatrixRoom,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
private val mediaSender: MediaSender,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Presenter<MessageComposerState> {
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Composable
|
||||
override fun present(): MessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
|
||||
if (uri == null) return@registerGalleryPicker
|
||||
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 attachmentsState = remember {
|
||||
mutableStateOf<AttachmentsState>(AttachmentsState.None)
|
||||
}
|
||||
|
||||
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 ->
|
||||
if (uri == null) return@registerCameraPhotoPicker
|
||||
Timber.d("Photo saved at $uri")
|
||||
appCoroutineScope.sendMedia(uri, MediaType.Image, deleteOriginal = true)
|
||||
handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
|
||||
}
|
||||
|
||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||
if (uri == null) return@registerCameraVideoPicker
|
||||
Timber.d("Video saved at $uri")
|
||||
appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true)
|
||||
handlePickedMedia(attachmentsState, uri, MimeTypes.VIDEO_MP4)
|
||||
}
|
||||
|
||||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
|
@ -104,7 +93,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
mutableStateOf(MessageComposerMode.Normal(""))
|
||||
}
|
||||
|
||||
var attachmentSourcePicker: AttachmentSourcePicker? by remember { mutableStateOf(null) }
|
||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(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) {
|
||||
when (event) {
|
||||
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.SetMode -> composerMode.value = event.composerMode
|
||||
MessageComposerEvents.AddAttachment -> localCoroutineScope.ifMediaPickersEnabled {
|
||||
attachmentSourcePicker = AttachmentSourcePicker.AllMedia
|
||||
MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
showAttachmentSourcePicker = true
|
||||
}
|
||||
MessageComposerEvents.DismissAttachmentMenu -> attachmentSourcePicker = null
|
||||
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.ifMediaPickersEnabled {
|
||||
attachmentSourcePicker = null
|
||||
MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false
|
||||
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
showAttachmentSourcePicker = false
|
||||
galleryMediaPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.ifMediaPickersEnabled {
|
||||
attachmentSourcePicker = null
|
||||
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
showAttachmentSourcePicker = false
|
||||
filesPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickAttachmentSource.FromCamera -> localCoroutineScope.ifMediaPickersEnabled {
|
||||
attachmentSourcePicker = AttachmentSourcePicker.Camera
|
||||
}
|
||||
MessageComposerEvents.PickCameraAttachmentSource.Photo -> localCoroutineScope.ifMediaPickersEnabled {
|
||||
attachmentSourcePicker = null
|
||||
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
showAttachmentSourcePicker = false
|
||||
cameraPhotoPicker.launch()
|
||||
}
|
||||
MessageComposerEvents.PickCameraAttachmentSource.Video -> localCoroutineScope.ifMediaPickersEnabled {
|
||||
attachmentSourcePicker = null
|
||||
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled {
|
||||
showAttachmentSourcePicker = false
|
||||
cameraVideoPicker.launch()
|
||||
}
|
||||
}
|
||||
|
|
@ -154,12 +147,13 @@ class MessageComposerPresenter @Inject constructor(
|
|||
text = text.value,
|
||||
isFullScreen = isFullScreen.value,
|
||||
mode = composerMode.value,
|
||||
attachmentSourcePicker = attachmentSourcePicker,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
attachmentsState = attachmentsState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.ifMediaPickersEnabled(action: suspend () -> Unit) = launch {
|
||||
private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch {
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
|
||||
action()
|
||||
}
|
||||
|
|
@ -190,44 +184,59 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMedia(
|
||||
uri: Uri,
|
||||
mediaType: MediaType,
|
||||
deleteOriginal: Boolean = false
|
||||
private fun CoroutineScope.sendAttachment(
|
||||
attachment: Attachment,
|
||||
attachmentState: MutableState<AttachmentsState>,
|
||||
) = launch {
|
||||
runCatching {
|
||||
val info = handleMediaPreProcessing(uri, mediaType, deleteOriginal).getOrNull() ?: return@runCatching
|
||||
when (info) {
|
||||
is MediaUploadInfo.Image -> {
|
||||
room.sendImage(info.file, info.thumbnailInfo.file, info.info)
|
||||
}
|
||||
|
||||
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")
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
sendMedia(
|
||||
uri = attachment.localMedia.uri,
|
||||
mimeType = attachment.localMedia.mimeType,
|
||||
attachmentState = attachmentState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMediaPreProcessing(
|
||||
uri: Uri,
|
||||
mediaType: MediaType,
|
||||
deleteOriginal: Boolean,
|
||||
): Result<MediaUploadInfo> {
|
||||
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
|
||||
Timber.d("Pre-processed media result: $result")
|
||||
return result.onFailure {
|
||||
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_processing))
|
||||
@UnstableApi
|
||||
private fun handlePickedMedia(
|
||||
attachmentsState: MutableState<AttachmentsState>,
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
compressIfPossible: Boolean = true,
|
||||
) {
|
||||
if (uri == null) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,24 +14,29 @@
|
|||
* 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.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
data class MessageComposerState(
|
||||
val text: StableCharSequence?,
|
||||
val isFullScreen: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
val attachmentSourcePicker: AttachmentSourcePicker?,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit
|
||||
) {
|
||||
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
|
||||
}
|
||||
|
||||
sealed interface AttachmentSourcePicker {
|
||||
object AllMedia : AttachmentSourcePicker
|
||||
object Camera : AttachmentSourcePicker
|
||||
@Immutable
|
||||
sealed interface AttachmentsState {
|
||||
object None : AttachmentsState
|
||||
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
|
||||
data class Sending(val attachments: ImmutableList<Attachment>) : AttachmentsState
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* 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 io.element.android.libraries.core.data.StableCharSequence
|
||||
|
|
@ -31,6 +31,7 @@ fun aMessageComposerState() = MessageComposerState(
|
|||
text = StableCharSequence(""),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal(content = ""),
|
||||
attachmentSourcePicker = null,
|
||||
showAttachmentSourcePicker = false,
|
||||
attachmentsState = AttachmentsState.None,
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* 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.ui.Modifier
|
||||
|
|
@ -32,8 +32,8 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlin.random.Random
|
||||
|
||||
fun aTimelineState() = TimelineState(
|
||||
timelineItems = persistentListOf(),
|
||||
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
|
||||
timelineItems = timelineItems,
|
||||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
||||
highlightedEventId = null,
|
||||
eventSink = {}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
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.layout.Box
|
||||
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.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -53,7 +54,6 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
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.PreviewParameter
|
||||
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.timeline.components.MessageEventBubble
|
||||
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.event.TimelineItemEventContentView
|
||||
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.event.TimelineItemEventContent
|
||||
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.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.TimelineItemLoadingModel
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
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.Icon
|
||||
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.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@Composable
|
||||
fun TimelineView(
|
||||
|
|
@ -234,6 +233,7 @@ fun TimelineItemEventRow(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
||||
Pair(Alignment.CenterEnd, Alignment.End)
|
||||
} else {
|
||||
|
|
@ -257,12 +257,13 @@ fun TimelineItemEventRow(
|
|||
Modifier.zIndex(1f)
|
||||
)
|
||||
}
|
||||
val bubbleState = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
isMine = event.isMine,
|
||||
isHighlighted = isHighlighted,
|
||||
)
|
||||
MessageEventBubble(
|
||||
state = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
isMine = event.isMine,
|
||||
isHighlighted = isHighlighted,
|
||||
),
|
||||
state = bubbleState,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
|
|
@ -270,21 +271,12 @@ fun TimelineItemEventRow(
|
|||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
Column {
|
||||
val contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 6.dp)
|
||||
TimelineItemEventContentView(event.content, interactionSource, onClick, onLongClick, contentModifier)
|
||||
TimestampView(
|
||||
formattedTime = event.sentTime,
|
||||
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),
|
||||
)
|
||||
}
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
interactionSource = interactionSource,
|
||||
onMessageClick = onClick,
|
||||
onMessageLongClick = onLongClick
|
||||
)
|
||||
}
|
||||
TimelineItemReactionsView(
|
||||
reactionsState = event.reactionsState,
|
||||
|
|
@ -329,44 +321,66 @@ fun TimelineItemStateEventRow(
|
|||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = contentModifier
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimestampView(
|
||||
formattedTime: String,
|
||||
isMessageEdited: Boolean,
|
||||
hasMessageSendingFailed: Boolean,
|
||||
onClick: () -> Unit,
|
||||
fun MessageEventBubbleContent(
|
||||
event: TimelineItem.Event,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onMessageClick: () -> Unit,
|
||||
onMessageLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
|
||||
Row(modifier = modifier.clickable(onClick = onClick)){
|
||||
if (isMessageEdited) {
|
||||
Text(
|
||||
stringResource(StringR.string.common_edited_suffix),
|
||||
style = ElementTextStyles.Regular.caption2,
|
||||
color = tint ?: MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
formattedTime,
|
||||
style = ElementTextStyles.Regular.caption1,
|
||||
color = tint ?: MaterialTheme.colorScheme.secondary,
|
||||
val showTimestampWithOverlay = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent
|
||||
|
||||
@Composable
|
||||
fun ContentView(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onMessageClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
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) {
|
||||
val timelineItems = aTimelineItemList(content)
|
||||
TimelineView(
|
||||
state = aTimelineState().copy(
|
||||
timelineItems = timelineItems,
|
||||
)
|
||||
state = aTimelineState(timelineItems)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -17,15 +17,19 @@
|
|||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
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.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.TimelineItemRedactedContent
|
||||
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.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
|
|
@ -56,6 +60,14 @@ fun TimelineItemEventContentView(
|
|||
modifier = modifier
|
||||
)
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemFileContent -> TimelineItemFileView(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -16,56 +16,34 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.graphics.painter.ColorPainter
|
||||
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.PreviewParameter
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
|
||||
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.libraries.designsystem.preview.ElementPreviewDark
|
||||
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
|
||||
fun TimelineItemImageView(
|
||||
content: TimelineItemImageContent,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val widthPercent = if (content.aspectRatio > 1f) {
|
||||
1f
|
||||
} else {
|
||||
0.7f
|
||||
}
|
||||
Box(
|
||||
TimelineItemAspectRatioBox(
|
||||
height = content.height,
|
||||
aspectRatio = content.aspectRatio,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(widthPercent)
|
||||
.aspectRatio(content.aspectRatio),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val isLoading = rememberSaveable(content.imageMeta) { mutableStateOf(true) }
|
||||
val context = LocalContext.current
|
||||
val model = ImageRequest.Builder(context)
|
||||
.data(content.imageMeta)
|
||||
.build()
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = null,
|
||||
placeholder = debugPlaceholderBackground(ColorPainter(MaterialTheme.colorScheme.surfaceVariant)),
|
||||
contentScale = ContentScale.Crop,
|
||||
onSuccess = { isLoading.value = false },
|
||||
BlurHashAsyncImage(
|
||||
model = MediaRequestData(content.mediaSource, MediaRequestData.Kind.Content),
|
||||
blurHash = content.blurhash,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.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.TimelineItemNoticeContent
|
||||
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.TimelineItemVideoContent
|
||||
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.FileMessageType
|
||||
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.NoticeMessageType
|
||||
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
|
||||
|
||||
class TimelineItemContentMessageFactory @Inject constructor() {
|
||||
|
|
@ -41,23 +44,38 @@ class TimelineItemContentMessageFactory @Inject constructor() {
|
|||
isEdited = content.isEdited,
|
||||
)
|
||||
is ImageMessageType -> {
|
||||
val height = messageType.info?.height?.toFloat()
|
||||
val width = messageType.info?.width?.toFloat()
|
||||
val aspectRatio = if (height != null && width != null) {
|
||||
width / height
|
||||
} else {
|
||||
0.7f
|
||||
}
|
||||
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
|
||||
TimelineItemImageContent(
|
||||
body = messageType.body,
|
||||
imageMeta = MediaResolver.Meta(
|
||||
url = messageType.url,
|
||||
kind = MediaResolver.Kind.Content
|
||||
),
|
||||
mediaSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype,
|
||||
blurhash = messageType.info?.blurhash,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
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(
|
||||
body = messageType.body,
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||
|
|
@ -71,4 +89,12 @@ class TimelineItemContentMessageFactory @Inject constructor() {
|
|||
else -> TimelineItemUnknownContent
|
||||
}
|
||||
}
|
||||
|
||||
private fun aspectRatioOf(width: Long?, height: Long?): Float {
|
||||
return if (height != null && width != null) {
|
||||
width.toFloat() / height.toFloat()
|
||||
} else {
|
||||
0.7f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.event.TimelineItemEmoteContent
|
||||
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.TimelineItemNoticeContent
|
||||
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.TimelineItemTextContent
|
||||
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 kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
|
|
@ -66,6 +68,8 @@ class TimelineItemGrouper @Inject constructor() {
|
|||
is TimelineItemEmoteContent,
|
||||
is TimelineItemNoticeContent,
|
||||
is TimelineItemTextContent,
|
||||
is TimelineItemFileContent,
|
||||
is TimelineItemVideoContent,
|
||||
TimelineItemUnknownContent -> false
|
||||
is TimelineItemProfileChangeContent,
|
||||
is TimelineItemRoomMembershipContent,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||
override val values = sequenceOf(
|
||||
aTimelineItemEmoteContent(),
|
||||
aTimelineItemEncryptedContent(),
|
||||
// TODO MessagesTimelineItemImageContent(),
|
||||
aTimelineItemImageContent(),
|
||||
aTimelineItemVideoContent(),
|
||||
aTimelineItemFileContent("A file.pdf"),
|
||||
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
|
||||
aTimelineItemNoticeContent(),
|
||||
aTimelineItemRedactedContent(),
|
||||
aTimelineItemTextContent(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -16,13 +16,16 @@
|
|||
|
||||
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(
|
||||
val body: String,
|
||||
val imageMeta: MediaResolver.Meta,
|
||||
val mediaSource: MediaSource,
|
||||
val mimeType: String?,
|
||||
val blurhash: String?,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val aspectRatio: Float
|
||||
) : TimelineItemEventContent{
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
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.MediaResolver
|
||||
import androidx.media3.common.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
||||
open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> {
|
||||
override val values: Sequence<TimelineItemImageContent>
|
||||
|
|
@ -30,7 +31,10 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
|
|||
|
||||
fun aTimelineItemImageContent() = TimelineItemImageContent(
|
||||
body = "a body",
|
||||
imageMeta = MediaResolver.Meta(url = null, kind = MediaResolver.Kind.Content),
|
||||
blurhash = null,
|
||||
aspectRatio = 0.5f,
|
||||
mediaSource = MediaSource(""),
|
||||
mimeType = MimeTypes.IMAGE_JPEG,
|
||||
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
||||
width = null,
|
||||
height = 300,
|
||||
aspectRatio = 0.5f
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.actionlist.ActionListPresenter
|
||||
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.groups.TimelineItemGrouper
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
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.room.FakeMatrixRoom
|
||||
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.textcomposer.MessageComposerMode
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
|
@ -134,7 +136,8 @@ class MessagesPresenterTest {
|
|||
room = matrixRoom,
|
||||
mediaPickerProvider = FakePickerProvider(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
mediaPreProcessor = FakeMediaPreProcessor(),
|
||||
localMediaFactory = FakeLocalMediaFactory(),
|
||||
mediaSender = MediaSender(FakeMediaPreProcessor(),matrixRoom),
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
)
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
|
|
@ -153,4 +156,3 @@ class MessagesPresenterTest {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -23,10 +23,11 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
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.mimetype.MimeTypes
|
||||
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.test.FakePickerProvider
|
||||
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.ThumbnailProcessingInfo
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
|
|
@ -53,8 +55,6 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
|
|||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
|
@ -64,13 +64,12 @@ class MessageComposerPresenterTest {
|
|||
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
|
||||
}
|
||||
private val featureFlagService = FakeFeatureFlagService().apply {
|
||||
runBlocking {
|
||||
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
|
||||
}
|
||||
}
|
||||
private val featureFlagService = FakeFeatureFlagService(
|
||||
mapOf(FeatureFlags.ShowMediaUploadingFlow.key to true)
|
||||
)
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
private val snackbarDispatcher = SnackbarDispatcher()
|
||||
private val localMediaFactory = FakeLocalMediaFactory()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
|
|
@ -82,6 +81,8 @@ class MessageComposerPresenterTest {
|
|||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||
assertThat(initialState.isSendButtonVisible).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -259,22 +260,9 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showAttachmentSourcePicker).isEqualTo(false)
|
||||
initialState.eventSink(MessageComposerEvents.AddAttachment)
|
||||
|
||||
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)
|
||||
assertThat(awaitItem().showAttachmentSourcePicker).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,7 +277,7 @@ class MessageComposerPresenterTest {
|
|||
skipItems(1)
|
||||
|
||||
initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||
assertThat(awaitItem().attachmentSourcePicker).isNull()
|
||||
assertThat(awaitItem().showAttachmentSourcePicker).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -308,7 +296,7 @@ class MessageComposerPresenterTest {
|
|||
mimetype = null,
|
||||
size = null,
|
||||
thumbnailInfo = null,
|
||||
thumbnailUrl = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
),
|
||||
thumbnailInfo = ThumbnailProcessingInfo(
|
||||
|
|
@ -329,9 +317,9 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||
// Wait for the launched upload coroutine to run
|
||||
runCurrent()
|
||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||
val previewingState = awaitItem()
|
||||
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -351,7 +339,7 @@ class MessageComposerPresenterTest {
|
|||
duration = null,
|
||||
size = null,
|
||||
thumbnailInfo = null,
|
||||
thumbnailUrl = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
),
|
||||
thumbnailInfo = ThumbnailProcessingInfo(
|
||||
|
|
@ -372,22 +360,9 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||
// Wait for the launched upload coroutine to run
|
||||
runCurrent()
|
||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
val previewingState = awaitItem()
|
||||
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -416,8 +391,11 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||
// Wait for the launched upload coroutine to run
|
||||
runCurrent()
|
||||
val sendingState = awaitItem()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -430,10 +408,11 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
|
||||
// Wait for the launched upload coroutine to run
|
||||
runCurrent()
|
||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
|
||||
val previewingState = awaitItem()
|
||||
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,10 +424,10 @@ class MessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
|
||||
// Wait for the launched upload coroutine to run
|
||||
runCurrent()
|
||||
assertThat(room.sendMediaCount).isEqualTo(1)
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
|
||||
val previewingState = awaitItem()
|
||||
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -463,10 +442,11 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
val initialState = awaitItem()
|
||||
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 {
|
||||
// Initial value is always null
|
||||
skipItems(1)
|
||||
// Assert error message received
|
||||
assertThat(awaitItem()).isNotNull()
|
||||
}
|
||||
|
|
@ -490,7 +470,13 @@ class MessageComposerPresenterTest {
|
|||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
||||
) = MessageComposerPresenter(
|
||||
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher
|
||||
coroutineScope,
|
||||
room,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
localMediaFactory,
|
||||
MediaSender(mediaPreProcessor, room),
|
||||
snackbarDispatcher
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ recyclerview = "1.3.0"
|
|||
lifecycle = "2.6.1"
|
||||
activity = "1.7.2"
|
||||
startup = "1.1.1"
|
||||
media3 = "1.0.2"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2023.05.01"
|
||||
|
|
@ -41,6 +42,7 @@ appyx = "1.2.0"
|
|||
dependencycheck = "8.2.1"
|
||||
stem = "2.3.0"
|
||||
sqldelight = "1.5.5"
|
||||
telephoto = "0.3.0"
|
||||
|
||||
# DI
|
||||
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_splash = "androidx.core:core-splashscreen:1.0.1"
|
||||
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_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"
|
||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
|
||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||
|
||||
# Di
|
||||
inject = "javax.inject:javax.inject:1"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -18,8 +18,6 @@ package io.element.android.libraries.androidutils.file
|
|||
|
||||
import android.content.Context
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
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" }
|
||||
File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
|
||||
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,12 @@ package io.element.android.libraries.androidutils.uri
|
|||
|
||||
import android.net.Uri
|
||||
|
||||
const val ASSET_FILE_PATH_ROOT = "android_asset"
|
||||
const val IGNORED_SCHEMA = "ignored"
|
||||
|
||||
fun Uri.isIgnored() = scheme == IGNORED_SCHEMA
|
||||
|
||||
fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path")
|
||||
|
||||
val Uri.firstPathSegment: String?
|
||||
get() = pathSegments.firstOrNull()
|
||||
|
|
|
|||
|
|
@ -25,3 +25,25 @@ inline fun <R, T : R> Result<T>.mapFailure(transform: (exception: Throwable) ->
|
|||
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) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -75,5 +75,7 @@ val LinkColor = Color(0xFF0086E6)
|
|||
val TextColorCriticalLight = Color(0xFFD51928)
|
||||
val TextColorCriticalDark = Color(0xfffd3e3c)
|
||||
|
||||
val Gray_400_Light = Color(0xFFE1E6EC)
|
||||
val Gray_400_Dark = Color(0xFF26282D)
|
||||
val Compound_Gray_300_Light = Color(0xFFF0F2F5)
|
||||
val Compound_Gray_300_Dark = Color(0xFF1D1F24)
|
||||
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
|
||||
val Compound_Gray_400_Dark = Color(0xFF26282D)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -23,10 +23,11 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import io.element.android.libraries.designsystem.Azure
|
||||
import io.element.android.libraries.designsystem.Black_800
|
||||
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.Gray_300
|
||||
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.SystemGrey5Dark
|
||||
import io.element.android.libraries.designsystem.SystemGrey6Dark
|
||||
|
|
@ -39,7 +40,8 @@ fun elementColorsDark() = ElementColors(
|
|||
messageHighlightedBackground = Azure,
|
||||
quaternary = Gray_400,
|
||||
quinary = Gray_450,
|
||||
gray400 = Gray_400_Dark,
|
||||
gray300 = Compound_Gray_300_Dark,
|
||||
gray400 = Compound_Gray_400_Dark,
|
||||
textActionCritical = TextColorCriticalDark,
|
||||
isLight = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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_200
|
||||
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.SystemGrey5Light
|
||||
import io.element.android.libraries.designsystem.SystemGrey6Light
|
||||
|
|
@ -39,7 +40,8 @@ fun elementColorsLight() = ElementColors(
|
|||
messageHighlightedBackground = Azure,
|
||||
quaternary = Gray_100,
|
||||
quinary = Gray_50,
|
||||
gray400 = Gray_400_Light,
|
||||
gray300 = Compound_Gray_300_Light,
|
||||
gray400 = Compound_Gray_400_Light,
|
||||
textActionCritical = TextColorCriticalLight,
|
||||
isLight = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class ElementColors(
|
|||
messageHighlightedBackground: Color,
|
||||
quaternary: Color,
|
||||
quinary: Color,
|
||||
gray300: Color,
|
||||
gray400: Color,
|
||||
textActionCritical: Color,
|
||||
isLight: Boolean
|
||||
|
|
@ -46,6 +47,9 @@ class ElementColors(
|
|||
var quinary by mutableStateOf(quinary)
|
||||
private set
|
||||
|
||||
var gray300 by mutableStateOf(gray400)
|
||||
private set
|
||||
|
||||
var gray400 by mutableStateOf(gray400)
|
||||
private set
|
||||
|
||||
|
|
@ -61,6 +65,7 @@ class ElementColors(
|
|||
messageHighlightedBackground: Color = this.messageHighlightedBackground,
|
||||
quaternary: Color = this.quaternary,
|
||||
quinary: Color = this.quinary,
|
||||
gray300: Color = this.gray300,
|
||||
gray400: Color = this.gray400,
|
||||
textActionCritical: Color = this.textActionCritical,
|
||||
isLight: Boolean = this.isLight,
|
||||
|
|
@ -70,6 +75,7 @@ class ElementColors(
|
|||
messageHighlightedBackground = messageHighlightedBackground,
|
||||
quaternary = quaternary,
|
||||
quinary = quinary,
|
||||
gray300 = gray300,
|
||||
gray400 = gray400,
|
||||
textActionCritical = textActionCritical,
|
||||
isLight = isLight,
|
||||
|
|
@ -81,6 +87,7 @@ class ElementColors(
|
|||
messageHighlightedBackground = other.messageHighlightedBackground
|
||||
quaternary = other.quaternary
|
||||
quinary = other.quinary
|
||||
gray300 = other.gray300
|
||||
gray400 = other.gray400
|
||||
textActionCritical = other.textActionCritical
|
||||
isLight = other.isLight
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
|||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.accompanist.systemuicontroller.SystemUiController
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
||||
/**
|
||||
|
|
@ -55,7 +57,6 @@ fun ElementTheme(
|
|||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val useDarkIcons = !darkTheme
|
||||
val currentColor = remember(darkTheme) {
|
||||
colors.copy()
|
||||
}.apply { updateColorsFrom(colors) }
|
||||
|
|
@ -68,13 +69,7 @@ fun ElementTheme(
|
|||
else -> materialLightColors
|
||||
}
|
||||
SideEffect {
|
||||
systemUiController.setStatusBarColor(
|
||||
color = colorScheme.background
|
||||
)
|
||||
systemUiController.setSystemBarsColor(
|
||||
color = Color.Transparent,
|
||||
darkIcons = useDarkIcons
|
||||
)
|
||||
systemUiController.applyTheme(colorScheme = colorScheme, darkTheme = darkTheme)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DividerDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ import androidx.compose.ui.graphics.Shape
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.text.AnnotatedString
|
|||
import com.google.common.truth.Truth
|
||||
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.MediaSource
|
||||
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.EventContent
|
||||
|
|
@ -156,10 +157,10 @@ class DefaultRoomLastMessageFormatterTests {
|
|||
|
||||
val sharedContentMessagesTypes = arrayOf(
|
||||
TextMessageType(body, null),
|
||||
VideoMessageType(body, "url", null),
|
||||
AudioMessageType(body, "url", null),
|
||||
ImageMessageType(body, "url", null),
|
||||
FileMessageType(body, "url", null),
|
||||
VideoMessageType(body, MediaSource("url"), null),
|
||||
AudioMessageType(body, MediaSource("url"), null),
|
||||
ImageMessageType(body, MediaSource("url"), null),
|
||||
FileMessageType(body, MediaSource("url"), null),
|
||||
NoticeMessageType(body, null),
|
||||
EmoteMessageType(body, null),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.UserId
|
||||
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.pusher.PushersService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
|
@ -35,6 +35,7 @@ interface MatrixClient : Closeable {
|
|||
val sessionId: SessionId
|
||||
val roomSummaryDataSource: RoomSummaryDataSource
|
||||
val invitesDataSource: RoomSummaryDataSource
|
||||
val mediaLoader: MatrixMediaLoader
|
||||
fun getRoom(roomId: RoomId): MatrixRoom?
|
||||
fun findDM(userId: UserId): MatrixRoom?
|
||||
suspend fun ignoreUser(userId: UserId): Result<Unit>
|
||||
|
|
@ -45,24 +46,13 @@ interface MatrixClient : Closeable {
|
|||
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
||||
fun startSync()
|
||||
fun stopSync()
|
||||
fun mediaResolver(): MediaResolver
|
||||
fun sessionVerificationService(): SessionVerificationService
|
||||
fun pushersService(): PushersService
|
||||
fun notificationService(): NotificationService
|
||||
suspend fun logout()
|
||||
suspend fun loadUserDisplayName(): 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>
|
||||
|
||||
fun onSlidingSyncUpdate()
|
||||
|
||||
fun roomMembershipObserver(): RoomMembershipObserver
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@ data class FileInfo(
|
|||
val mimetype: String?,
|
||||
val size: Long?,
|
||||
val thumbnailInfo: ThumbnailInfo?,
|
||||
val thumbnailUrl: String?
|
||||
val thumbnailSource: MediaSource?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@ data class ImageInfo(
|
|||
val mimetype: String?,
|
||||
val size: Long?,
|
||||
val thumbnailInfo: ThumbnailInfo?,
|
||||
val thumbnailUrl: String?,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurhash: String?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -23,6 +23,6 @@ data class VideoInfo(
|
|||
val mimetype: String?,
|
||||
val size: Long?,
|
||||
val thumbnailInfo: ThumbnailInfo?,
|
||||
val thumbnailUrl: String?,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurhash: String?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.FileInfo
|
||||
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
|
||||
|
||||
sealed interface EventContent
|
||||
|
|
@ -106,25 +107,25 @@ data class EmoteMessageType(
|
|||
|
||||
data class ImageMessageType(
|
||||
val body: String,
|
||||
val url: String,
|
||||
val source: MediaSource,
|
||||
val info: ImageInfo?
|
||||
) : MessageType
|
||||
|
||||
data class AudioMessageType(
|
||||
var body: String,
|
||||
var url: String,
|
||||
var info: AudioInfo?
|
||||
val body: String,
|
||||
val source: MediaSource,
|
||||
val info: AudioInfo?
|
||||
) : MessageType
|
||||
|
||||
data class VideoMessageType(
|
||||
val body: String,
|
||||
val url: String,
|
||||
val source: MediaSource,
|
||||
val info: VideoInfo?
|
||||
) : MessageType
|
||||
|
||||
data class FileMessageType(
|
||||
val body: String,
|
||||
val url: String,
|
||||
val source: MediaSource,
|
||||
val info: FileInfo?
|
||||
) : MessageType
|
||||
|
||||
|
|
|
|||
|
|
@ -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.RoomPreset
|
||||
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.pusher.PushersService
|
||||
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.MatrixUser
|
||||
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.pushers.RustPushersService
|
||||
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.SlidingSyncRequestListFilters
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
|
@ -186,9 +185,12 @@ class RustMatrixClient constructor(
|
|||
override val invitesDataSource: RoomSummaryDataSource
|
||||
get() = rustInvitesDataSource
|
||||
|
||||
private val rustMediaLoader = RustMediaLoader(dispatchers, client)
|
||||
override val mediaLoader: MatrixMediaLoader
|
||||
get() = rustMediaLoader
|
||||
|
||||
private var slidingSyncObserverToken: TaskHandle? = null
|
||||
|
||||
private val mediaResolver = RustMediaResolver(this)
|
||||
private val isSyncing = AtomicBoolean(false)
|
||||
|
||||
private val roomMembershipObserver = RoomMembershipObserver()
|
||||
|
|
@ -288,8 +290,6 @@ class RustMatrixClient constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun mediaResolver(): MediaResolver = mediaResolver
|
||||
|
||||
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
||||
|
||||
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)
|
||||
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
|
|
|
|||
|
|
@ -22,16 +22,16 @@ import dagger.Provides
|
|||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
|
||||
@Module
|
||||
@ContributesTo(SessionScope::class)
|
||||
object SessionMatrixModule {
|
||||
@Provides
|
||||
@SingleIn(SessionScope::class)
|
||||
fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
|
||||
fun providesSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
|
||||
return matrixClient.sessionVerificationService()
|
||||
}
|
||||
|
||||
|
|
@ -40,4 +40,10 @@ object SessionMatrixModule {
|
|||
fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver {
|
||||
return matrixClient.roomMembershipObserver()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(SessionScope::class)
|
||||
fun provideMediaLoader(matrixClient: MatrixClient): MatrixMediaLoader {
|
||||
return matrixClient.mediaLoader
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,15 +17,13 @@
|
|||
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.ThumbnailInfo
|
||||
import org.matrix.rustcomponents.sdk.FileInfo as RustFileInfo
|
||||
import org.matrix.rustcomponents.sdk.ThumbnailInfo as RustThumbnailInfo
|
||||
|
||||
fun RustFileInfo.map(): FileInfo = FileInfo(
|
||||
mimetype = mimetype,
|
||||
size = size?.toLong(),
|
||||
thumbnailInfo = thumbnailInfo?.map(),
|
||||
thumbnailUrl = thumbnailSource?.useUrl()
|
||||
thumbnailSource = thumbnailSource?.map()
|
||||
)
|
||||
|
||||
fun FileInfo.map(): RustFileInfo = RustFileInfo(
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ fun RustImageInfo.map(): ImageInfo = ImageInfo(
|
|||
mimetype = mimetype,
|
||||
size = size?.toLong(),
|
||||
thumbnailInfo = thumbnailInfo?.map(),
|
||||
thumbnailUrl = thumbnailSource?.useUrl(),
|
||||
thumbnailSource = thumbnailSource?.map(),
|
||||
blurhash = blurhash
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@
|
|||
|
||||
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.MediaSource as RustMediaSource
|
||||
|
||||
fun MediaSource.useUrl(): String = use { it.url() }
|
||||
fun RustMediaSource.map(): MediaSource = use {
|
||||
MediaSource(it.url(), it.toJson())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
|
@ -16,21 +16,16 @@
|
|||
|
||||
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 {
|
||||
data class Thumbnail(val width: Int, val height: Int) : Kind {
|
||||
constructor(size: Int) : this(size, size)
|
||||
}
|
||||
class RustMediaFile(private val inner: MediaFileHandle) : MediaFile {
|
||||
|
||||
object Content : Kind
|
||||
override fun path(): String {
|
||||
return inner.path()
|
||||
}
|
||||
|
||||
data class Meta(
|
||||
val url: String?,
|
||||
val kind: Kind
|
||||
)
|
||||
|
||||
suspend fun resolve(url: String?, kind: Kind): ByteArray?
|
||||
|
||||
override fun close() {
|
||||
inner.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
|
|||
mimetype = mimetype,
|
||||
size = size?.toLong(),
|
||||
thumbnailInfo = thumbnailInfo?.map(),
|
||||
thumbnailUrl = thumbnailSource?.useUrl(),
|
||||
thumbnailSource = thumbnailSource?.map(),
|
||||
blurhash = blurhash
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -222,28 +222,27 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> {
|
||||
return runCatching {
|
||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> {
|
||||
return runCatching {
|
||||
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> {
|
||||
return runCatching {
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.sendAudio(file.path, audioInfo.map())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> {
|
||||
return runCatching {
|
||||
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.sendFile(file.path, fileInfo.map())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.VideoMessageType
|
||||
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.MessageType
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
|
|
@ -42,13 +41,13 @@ class EventMessageMapper {
|
|||
val type = it.msgtype().use { type ->
|
||||
when (type) {
|
||||
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 -> {
|
||||
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 -> {
|
||||
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 -> {
|
||||
NoticeMessageType(type.content.body, type.content.formatted?.map())
|
||||
|
|
@ -60,7 +59,7 @@ class EventMessageMapper {
|
|||
EmoteMessageType(type.content.body, type.content.formatted?.map())
|
||||
}
|
||||
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 -> {
|
||||
UnknownMessageType
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
|
|||
StickerContent(
|
||||
body = kind.body,
|
||||
info = kind.info.map(),
|
||||
url = kind.url
|
||||
url = kind.url,
|
||||
)
|
||||
}
|
||||
is TimelineItemContentKind.UnableToDecrypt -> {
|
||||
|
|
|
|||
|
|
@ -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.UserId
|
||||
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.pusher.PushersService
|
||||
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.MatrixUser
|
||||
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.pushers.FakePushersService
|
||||
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),
|
||||
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
|
||||
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
||||
private val pushersService: FakePushersService = FakePushersService(),
|
||||
private val notificationService: FakeNotificationService = FakeNotificationService(),
|
||||
|
|
@ -100,10 +101,6 @@ class FakeMatrixClient(
|
|||
|
||||
override fun stopSync() = Unit
|
||||
|
||||
override fun mediaResolver(): MediaResolver {
|
||||
return FakeMediaResolver()
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
delay(100)
|
||||
logoutFailure?.let { throw it }
|
||||
|
|
@ -119,14 +116,6 @@ class FakeMatrixClient(
|
|||
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> {
|
||||
return uploadMediaResult
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
|
|||
const val AN_AVATAR_URL = "mxc://data"
|
||||
|
||||
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 AN_EXCEPTION = Exception(A_FAILURE_REASON)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@
|
|||
|
||||
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 {
|
||||
override suspend fun resolve(url: String?, kind: MediaResolver.Kind): ByteArray? {
|
||||
return null
|
||||
class FakeMediaFile(private val path: String) : MediaFile {
|
||||
override fun path(): String {
|
||||
return path
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
|
|
@ -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(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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.test.A_ROOM_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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -99,7 +100,7 @@ class FakeMatrixRoom(
|
|||
}
|
||||
|
||||
override suspend fun sendMessage(message: String): Result<Unit> {
|
||||
delay(100)
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ class FakeMatrixRoom(
|
|||
|
||||
override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> {
|
||||
editMessageParameter = message
|
||||
delay(100)
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +118,7 @@ class FakeMatrixRoom(
|
|||
|
||||
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
|
||||
replyMessageParameter = message
|
||||
delay(100)
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +127,7 @@ class FakeMatrixRoom(
|
|||
|
||||
override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> {
|
||||
redactEventEventIdParam = eventId
|
||||
delay(100)
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
|
|
@ -150,13 +151,20 @@ class FakeMatrixRoom(
|
|||
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
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue