diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index cf43c9ceb6..df0013972d 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -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> ) = 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() } } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index de41e5aa7f..26f69ebf81 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt index 6b0df92f09..abf451b4b6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt @@ -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(buildContext, listOf(callback)) + return parentNode.createNode(buildContext, listOf(callback)) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt new file mode 100644 index 0000000000..1e10a553f1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -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, +) : BackstackNode( + 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().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) { + backstack.push(NavTarget.AttachmentPreview(attachments.first())) + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.MediaViewer -> { + val inputs = MediaViewerNode.Inputs( + name = navTarget.title, + mediaSource = navTarget.mediaSource, + thumbnailSource = navTarget.thumbnailSource, + mimeType = navTarget.mimeType, + ) + createNode(buildContext, listOf(inputs)) + } + is NavTarget.AttachmentPreview -> { + val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) + createNode(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, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index ebc7f9e829..626b3bd682 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -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().firstOrNull() + private val callback = plugins().firstOrNull() + + interface Callback : Plugin { + fun onRoomDetailsClicked() + fun onEventClicked(event: TimelineItem.Event) + fun onPreviewAttachments(attachments: ImmutableList) + } private fun onRoomDetailsClicked() { callback?.onRoomDetailsClicked() } + private fun onEventClicked(event: TimelineItem.Event) { + callback?.onEventClicked(event) + } + + private fun onPreviewAttachments(attachments: ImmutableList) { + 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, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 13f36c2ab6..c075d4321e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 34e5f664ea..8c876ea49c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index beaba74a8c..e37fd11540 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -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 { 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)), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index c1708d3086..e3c6160b20 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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) -> 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) -> 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 = {} + ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt new file mode 100644 index 0000000000..8739a45201 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt new file mode 100644 index 0000000000..14a6a3fb2d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt new file mode 100644 index 0000000000..370033774c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt @@ -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, + 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 + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt new file mode 100644 index 0000000000..d80359e88c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -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 { + + @AssistedFactory + interface Factory { + fun create(attachment: Attachment): AttachmentsPreviewPresenter + } + + @Composable + override fun present(): AttachmentsPreviewState { + + val coroutineScope = rememberCoroutineScope() + + val sendActionState = remember { + mutableStateOf>(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>, + ) = launch { + when (attachment) { + is Attachment.Media -> { + sendMedia( + mediaAttachment = attachment, + sendActionState = sendActionState + ) + } + } + } + + private suspend fun sendMedia( + mediaAttachment: Attachment.Media, + sendActionState: MutableState>, + ) { + suspend { + mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) + }.executeResult(sendActionState) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt new file mode 100644 index 0000000000..67350f5048 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -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, + val eventSink: (AttachmentsPreviewEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt new file mode 100644 index 0000000000..26565a226a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -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 { + override val values: Sequence + get() = sequenceOf( + anAttachmentsPreviewState(), + anAttachmentsPreviewState(sendActionState = Async.Loading()), + anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())), + ) +} + +fun anAttachmentsPreviewState(sendActionState: Async = Async.Uninitialized) = AttachmentsPreviewState( + attachment = Attachment.Media( + localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L), + compressIfPossible = true + ), + sendActionState = sendActionState, + eventSink = {} +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt new file mode 100644 index 0000000000..28c78da1be --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -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, + 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 = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt new file mode 100644 index 0000000000..92dcd21b8e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt @@ -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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt new file mode 100644 index 0000000000..bc2f1a066c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt @@ -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 + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt new file mode 100644 index 0000000000..8305c8eee7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt @@ -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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt new file mode 100644 index 0000000000..09c44f4fba --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt new file mode 100644 index 0000000000..3040f0bfbd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt @@ -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 + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt new file mode 100644 index 0000000000..d27b667883 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt @@ -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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/ExoPlayerWrapper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/ExoPlayerWrapper.kt new file mode 100644 index 0000000000..a69db1ef2c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/ExoPlayerWrapper.kt @@ -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() + ) + } + } +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt similarity index 75% rename from libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt index e16ca43699..b0bbad5ec2 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt @@ -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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt new file mode 100644 index 0000000000..247a86263f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt @@ -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, + 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 + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt new file mode 100644 index 0000000000..fb563461c5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt @@ -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 { + + @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 = remember { + mutableStateOf(null) + } + val localMedia: MutableState> = 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, localMedia: MutableState>) = 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) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt new file mode 100644 index 0000000000..c42263dd3e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt @@ -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, + val eventSink: (MediaViewerEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt new file mode 100644 index 0000000000..6c54eb3b05 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt @@ -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 { + override val values: Sequence + 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 = Async.Uninitialized) = MediaViewerState( + name = "A media", + mimeType = MimeTypes.IMAGE_JPEG, + thumbnailSource = null, + downloadedMedia = downloadedMedia, +) {} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt new file mode 100644 index 0000000000..ae598688d1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt @@ -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, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt similarity index 82% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index b3728d1367..155e981fcc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -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 } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt similarity index 56% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 99d0f98b66..ae975f4ddb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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 { + @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.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, ) = 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 { - 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, + 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, + ) { + mediaSender.sendMedia(uri, mimeType, compressIfPossible = false) + .onSuccess { + attachmentState.value = AttachmentsState.None + }.onFailure { + val snackbarMessage = SnackbarMessage(sendAttachmentError(it)) + snackbarDispatcher.post(snackbarMessage) + attachmentState.value = AttachmentsState.None + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt similarity index 66% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerState.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 7824f1f242..9c6a2c650a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -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) : AttachmentsState + data class Sending(val attachments: ImmutableList) : AttachmentsState } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt similarity index 88% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerStateProvider.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 38a88ee91f..56d050e7b1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -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 = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt similarity index 97% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 9b167013da..63fe656cd2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 55f77b3379..a8e17ea462 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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 = persistentListOf()) = TimelineState( + timelineItems = timelineItems, paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true), highlightedEventId = null, eventSink = {} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index afae1e5740..0f31652ec8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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) ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt new file mode 100644 index 0000000000..8358d6cc1e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -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)) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt new file mode 100644 index 0000000000..9237e87e9d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt @@ -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 + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt new file mode 100644 index 0000000000..043019cc7f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt @@ -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 + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt index 103b444cf9..735c4e8106 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt @@ -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 ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt new file mode 100644 index 0000000000..0628b2050e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -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) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index a532557ffb..f2cb2b2b92 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -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, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt new file mode 100644 index 0000000000..883f7b30f3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -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) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 04023b6370..21a7a11e31 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -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 + } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt index 3cd1b8fd4a..24f74f4b5c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 187b22f33a..71f6869cc0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -24,7 +24,10 @@ class TimelineItemEventContentProvider : PreviewParameterProvider { + override val values: Sequence + 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 +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 1d2367d13f..850dc9782c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -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" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt index ba79c9988f..97bbf6ed41 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt @@ -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 { override val values: Sequence @@ -30,7 +31,10 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider { + override val values: Sequence + 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 +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/Modifiers.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/Modifiers.kt new file mode 100644 index 0000000000..611e270742 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/Modifiers.kt @@ -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) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index f01c8581ab..1d8ee54505 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -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 { ) } } - diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt new file mode 100644 index 0000000000..0b16a254a1 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt @@ -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()) + 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()) + testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) + val failureState = awaitItem() + assertThat(failureState.sendActionState).isEqualTo(Async.Failure(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) + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt new file mode 100644 index 0000000000..ce5847f559 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt @@ -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, +) + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt new file mode 100644 index 0000000000..0382941d87 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt @@ -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) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt new file mode 100644 index 0000000000..16f2894303 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt @@ -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 + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index 0c0b46cbe2..01c6e0eb49 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -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 ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e70c367d20..49823363bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt new file mode 100644 index 0000000000..6bf784b100 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt @@ -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() diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 80df69cbcc..581d45a2b0 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -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() } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt index 485a103b5b..1375104b79 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt @@ -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() diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt index baa3b35e2b..f7d96aebf5 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt @@ -25,3 +25,25 @@ inline fun Result.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 Result.flatMap(transform: (T) -> Result): Result { + 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 Result.flatMapCatching(transform: (T) -> Result): Result { + return mapCatching(transform).fold( + onSuccess = { it }, + onFailure = { Result.failure(it) } + ) +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTests.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTests.kt new file mode 100644 index 0000000000..de5703b090 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTests.kt @@ -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(IllegalStateException("error")) } + + assertThat(otherResult.getOrNull()).isEqualTo("other") + assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error") + try { + initial.flatMap { error("caught error") } + } catch (e: IllegalStateException) { + assertThat(e.message).isEqualTo("caught error") + } + + val initialError = Result.failure(IllegalStateException("initial error")) + val mapErrorToSuccess = initialError.flatMap { Result.success("other") } + val mapErrorToError = initialError.flatMap { Result.failure(IllegalStateException("error")) } + val mapErrorAndCatch: Result = 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(IllegalStateException("error")) } + val caughtExceptionResult: Result = 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(IllegalStateException("initial error")) + val mapErrorToSuccess = initialError.flatMapCatching { Result.success("other") } + val mapErrorToError = initialError.flatMapCatching { Result.failure(IllegalStateException("error")) } + val mapErrorAndCatch: Result = 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") + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/Color.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/Color.kt index 60f355d054..50585f9dba 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/Color.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/Color.kt @@ -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) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/RoundedBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/RoundedBackground.kt new file mode 100644 index 0000000000..e9531c3726 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/RoundedBackground.kt @@ -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) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt index e697ed782c..166a2f3f79 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt @@ -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, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt index 35fdcf29b1..085dd534cd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt @@ -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, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt index b275d66dd5..2643e678d3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt @@ -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 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTheme.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTheme.kt index 883346571a..83de1b63a1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTheme.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTheme.kt @@ -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 + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt index 41c987d7b2..692e89f8e8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt @@ -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 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt index f4496ca87f..db7ab7fc08 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt @@ -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 diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt index 6e8eb3d81c..ad1ef259b7 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt @@ -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), ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 6d357ad912..f0dec42855 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -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 @@ -45,24 +46,13 @@ interface MatrixClient : Closeable { suspend fun searchUsers(searchTerm: String, limit: Long): Result fun startSync() fun stopSync() - fun mediaResolver(): MediaResolver fun sessionVerificationService(): SessionVerificationService fun pushersService(): PushersService fun notificationService(): NotificationService suspend fun logout() suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result - suspend fun loadMediaContent(url: String): Result - suspend fun loadMediaThumbnail( - url: String, - width: Long, - height: Long - ): Result - suspend fun uploadMedia(mimeType: String, data: ByteArray): Result - fun onSlidingSyncUpdate() - fun roomMembershipObserver(): RoomMembershipObserver - } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt index fc591a5078..0b99e5f6bc 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt @@ -20,5 +20,5 @@ data class FileInfo( val mimetype: String?, val size: Long?, val thumbnailInfo: ThumbnailInfo?, - val thumbnailUrl: String? + val thumbnailSource: MediaSource? ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt index 540627470e..b77fc2f4c2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt @@ -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? ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt new file mode 100644 index 0000000000..4d1d2445ce --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt @@ -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 + + /** + * @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 + + /** + * @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 +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt new file mode 100644 index 0000000000..3ef659133d --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt @@ -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 +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt new file mode 100644 index 0000000000..170137302b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt @@ -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 diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt index c2d74fc2f6..aa291bd653 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt @@ -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? ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index 63d3ce3911..dafaa95936 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -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 diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 7bc84fb6dd..bac98dd852 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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 = - 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 = - 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 = withContext(dispatchers.io) { runCatching { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index b49050cdc2..104a204164 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -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 + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt index a13c48efc5..c287c9446f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt @@ -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( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt index 21130ab86e..b66cec96fd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt @@ -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 ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt index 2fc50611e8..c70bd0640f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt @@ -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()) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaResolver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaFile.kt similarity index 61% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaResolver.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaFile.kt index ca840ee44f..4b26b8c6c6 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaResolver.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaFile.kt @@ -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() + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt new file mode 100644 index 0000000000..9e4f2c53de --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -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 = + 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 = + 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 = + 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) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaResolver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaResolver.kt deleted file mode 100644 index 68ed48350e..0000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaResolver.kt +++ /dev/null @@ -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() - } -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt index 9d03e2be2f..b474c2ab2e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt @@ -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 ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 63bdf6fccc..55576c7b96 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -222,28 +222,27 @@ class RustMatrixRoom( } } - override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result { - return runCatching { + override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result = withContext(coroutineDispatchers.io) { + runCatching { innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map()) } } - override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result { - return runCatching { + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = withContext(coroutineDispatchers.io) { + runCatching { innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map()) } } - override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result { - return runCatching { + override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = withContext(coroutineDispatchers.io) { + runCatching { innerRoom.sendAudio(file.path, audioInfo.map()) } } - override suspend fun sendFile(file: File, fileInfo: FileInfo): Result { - return runCatching { + override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = withContext(coroutineDispatchers.io) { + runCatching { innerRoom.sendFile(file.path, fileInfo.map()) } } - } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 674e02a13d..2e4693c1fb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -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 diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index f776c52670..33727c15d4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -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 -> { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index bdf76c8e05..85c3555844 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -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 = 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 { - return Result.success(ByteArray(0)) - } - - override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result { - return Result.success(ByteArray(0)) - } - override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result { return uploadMediaResult } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 60e8c0caa0..d9322ed2ae 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -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) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaResolver.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaFile.kt similarity index 75% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaResolver.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaFile.kt index 4d5ebc8029..275580d11e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaResolver.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaFile.kt @@ -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 } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt new file mode 100644 index 0000000000..96c49aa165 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt @@ -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 { + 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 { + 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 { + delay(FAKE_DELAY_IN_MS) + return if (shouldFail) { + Result.failure(RuntimeException()) + } else { + return Result.success(FakeMediaFile("")) + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/MediaSource.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/MediaSource.kt new file mode 100644 index 0000000000..4a0e9005d2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/MediaSource.kt @@ -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 +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 5b90f1b915..ff91db14b3 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -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 { - 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 { 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 { 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 { 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 = sendMediaResult.also { sendMediaCount++ } + override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result = fakeSendMedia() - override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = sendMediaResult.also { sendMediaCount++ } + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = fakeSendMedia() - override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = sendMediaResult.also { sendMediaCount++ } + override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = fakeSendMedia() - override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = sendMediaResult.also { sendMediaCount++ } + override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = fakeSendMedia() + + private suspend fun fakeSendMedia(): Result { + delay(FAKE_DELAY_IN_MS) + return sendMediaResult.onSuccess { + sendMediaCount++ + } + } override fun close() = Unit diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatatarDataExt.kt similarity index 70% rename from libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt rename to libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatatarDataExt.kt index 6301503797..39912cb443 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatatarDataExt.kt @@ -17,9 +17,12 @@ package io.element.android.libraries.matrix.ui.media import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.api.media.MediaResolver -import kotlin.math.roundToInt +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.math.roundToLong -fun AvatarData.toMetadata(): MediaResolver.Meta { - return MediaResolver.Meta(url = url, kind = MediaResolver.Kind.Thumbnail(size.dp.value.roundToInt())) +fun AvatarData.toMediaRequestData(): MediaRequestData { + return MediaRequestData( + source = url?.let { MediaSource(it) }, + kind = MediaRequestData.Kind.Thumbnail(size.dp.value.roundToLong()) + ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaFetcher.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt similarity index 54% rename from libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaFetcher.kt rename to libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index 6567101162..d638db2902 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaFetcher.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -22,32 +22,46 @@ import coil.fetch.Fetcher import coil.request.Options import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.media.MediaResolver +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import java.nio.ByteBuffer -internal class MediaFetcher( - private val mediaResolver: MediaResolver?, - private val meta: MediaResolver.Meta, +internal class CoilMediaFetcher( + private val mediaLoader: MatrixMediaLoader, + private val mediaData: MediaRequestData?, private val options: Options, private val imageLoader: ImageLoader ) : Fetcher { override suspend fun fetch(): FetchResult? { - val byteArray = mediaResolver?.resolve(meta.url, meta.kind) ?: return null - val byteBuffer = ByteBuffer.wrap(byteArray) - return imageLoader.components.newFetcher(byteBuffer, options, imageLoader)?.first?.fetch() + return loadMedia() + .map { data -> + val byteBuffer = ByteBuffer.wrap(data) + imageLoader.components.newFetcher(byteBuffer, options, imageLoader)?.first?.fetch() + }.getOrThrow() } - class MetaFactory(private val client: MatrixClient) : - Fetcher.Factory { + private suspend fun loadMedia(): Result { + if (mediaData?.source == null) return Result.failure(IllegalStateException("No media data to fetch.")) + return when (mediaData.kind) { + is MediaRequestData.Kind.Content -> mediaLoader.loadMediaContent(source = mediaData.source) + is MediaRequestData.Kind.Thumbnail -> mediaLoader.loadMediaThumbnail( + source = mediaData.source, + width = mediaData.kind.width, + height = mediaData.kind.height + ) + } + } + + class MediaRequestDataFactory(private val client: MatrixClient) : + Fetcher.Factory { override fun create( - data: MediaResolver.Meta, + data: MediaRequestData, options: Options, imageLoader: ImageLoader ): Fetcher { - return MediaFetcher( - mediaResolver = client.mediaResolver(), - meta = data, + return CoilMediaFetcher( + mediaLoader = client.mediaLoader, + mediaData = data, options = options, imageLoader = imageLoader ) @@ -56,14 +70,15 @@ internal class MediaFetcher( class AvatarFactory(private val client: MatrixClient) : Fetcher.Factory { + override fun create( data: AvatarData, options: Options, imageLoader: ImageLoader ): Fetcher { - return MediaFetcher( - mediaResolver = client.mediaResolver(), - meta = data.toMetadata(), + return CoilMediaFetcher( + mediaLoader = client.mediaLoader, + mediaData = data.toMediaRequestData(), options = options, imageLoader = imageLoader ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt index a52cfd380a..d1fab05544 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -34,10 +34,10 @@ class LoggedInImageLoaderFactory @Inject constructor( .Builder(context) .okHttpClient(okHttpClient) .components { - add(AvatarKeyer()) - add(MediaKeyer()) - add(MediaFetcher.AvatarFactory(matrixClient)) - add(MediaFetcher.MetaFactory(matrixClient)) + add(AvatarDataKeyer()) + add(MediaRequestDataKeyer()) + add(CoilMediaFetcher.AvatarFactory(matrixClient)) + add(CoilMediaFetcher.MediaRequestDataFactory(matrixClient)) } .build() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaResolver.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt similarity index 63% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaResolver.kt rename to libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt index 52d674df90..02a7ed4e8c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaResolver.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt @@ -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. @@ -14,23 +14,21 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.api.media +package io.element.android.libraries.matrix.ui.media -interface MediaResolver { +import io.element.android.libraries.matrix.api.media.MediaSource + +data class MediaRequestData( + val source: MediaSource?, + val kind: Kind +) { sealed interface Kind { - data class Thumbnail(val width: Int, val height: Int) : Kind { - constructor(size: Int) : this(size, size) + data class Thumbnail(val width: Long, val height: Long) : Kind { + constructor(size: Long) : this(size, size) } object Content : Kind } - - data class Meta( - val url: String?, - val kind: Kind - ) - - suspend fun resolve(url: String?, kind: Kind): ByteArray? - } + diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaKeyer.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt similarity index 68% rename from libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaKeyer.kt rename to libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt index 6a1a5e8bfb..0064c1b63b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaKeyer.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt @@ -19,21 +19,20 @@ package io.element.android.libraries.matrix.ui.media import coil.key.Keyer import coil.request.Options import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.api.media.MediaResolver -internal class AvatarKeyer : Keyer { +internal class AvatarDataKeyer : Keyer { override fun key(data: AvatarData, options: Options): String? { - return data.toMetadata().toKey() + return data.toMediaRequestData().toKey() } } -internal class MediaKeyer : Keyer { - override fun key(data: MediaResolver.Meta, options: Options): String? { +internal class MediaRequestDataKeyer : Keyer { + override fun key(data: MediaRequestData, options: Options): String? { return data.toKey() } } -private fun MediaResolver.Meta.toKey(): String? { - if (url.isNullOrBlank()) return null - return "${url}_${kind}" +private fun MediaRequestData.toKey(): String? { + if (source == null) return null + return "${source.url}_${kind}" } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt index 6e2168ca4b..31c6a813ff 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -20,13 +20,17 @@ import android.net.Uri interface MediaPreProcessor { /** - * Given a [uri] and [mediaType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata. + * Given a [uri] and [mimeType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata. * If [deleteOriginal] is `true`, the file reference by the [uri] will be automatically deleted too when this process finishes. * @return a [Result] with the [MediaUploadInfo] containing all the info needed to begin the upload. */ suspend fun process( uri: Uri, - mediaType: MediaType, - deleteOriginal: Boolean = false + mimeType: String, + deleteOriginal: Boolean = false, + compressIfPossible: Boolean ): Result + + data class Failure(override val cause: Throwable?) : RuntimeException(cause) } + diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt new file mode 100644 index 0000000000..585670d939 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -0,0 +1,60 @@ +/* + * 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.mediaupload.api + +import android.net.Uri +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.matrix.api.room.MatrixRoom +import javax.inject.Inject + +class MediaSender @Inject constructor( + private val preProcessor: MediaPreProcessor, + private val room: MatrixRoom, +) { + + suspend fun sendMedia(uri: Uri, mimeType: String, compressIfPossible: Boolean): Result { + return preProcessor + .process( + uri = uri, + mimeType = mimeType, + deleteOriginal = true, + compressIfPossible = compressIfPossible + ) + .flatMap { info -> + room.sendMedia(info) + } + } + + private suspend fun MatrixRoom.sendMedia( + info: MediaUploadInfo, + ): Result { + return when (info) { + is MediaUploadInfo.Image -> { + sendImage(info.file, info.thumbnailInfo.file, info.info) + } + + is MediaUploadInfo.Video -> { + sendVideo(info.file, info.thumbnailInfo.file, info.info) + } + + is MediaUploadInfo.AnyFile -> { + sendFile(info.file, info.info) + } + else -> Result.failure(IllegalStateException("Unexpected MediaUploadInfo format: $info")) + } + } +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index 6696806e24..47fa26ae79 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -24,10 +24,13 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import java.io.File sealed interface MediaUploadInfo { - data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo - data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo - data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo - data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo + + val file: File + + data class Image(override val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo + data class Video(override val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo + data class Audio(override val file: File, val info: AudioInfo) : MediaUploadInfo + data class AnyFile(override val file: File, val info: FileInfo) : MediaUploadInfo } data class ThumbnailProcessingInfo( diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt similarity index 83% rename from libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt rename to libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt index c6dd7eb841..4882000307 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt @@ -23,22 +23,26 @@ import android.net.Uri import androidx.exifinterface.media.ExifInterface import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.file.getFileName import io.element.android.libraries.androidutils.media.runAndRelease +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext 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.ThumbnailInfo import io.element.android.libraries.matrix.api.media.VideoInfo 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.ThumbnailProcessingInfo -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach @@ -51,10 +55,11 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @ContributesBinding(AppScope::class) -class MediaPreProcessorImpl @Inject constructor( +class AndroidMediaPreProcessor @Inject constructor( @ApplicationContext private val context: Context, private val imageCompressor: ImageCompressor, private val videoCompressor: VideoCompressor, + private val coroutineDispatchers: CoroutineDispatchers, ) : MediaPreProcessor { companion object { /** @@ -70,6 +75,7 @@ class MediaPreProcessorImpl @Inject constructor( * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). */ private const val THUMB_MAX_WIDTH = 800 + /** * Max height of thumbnail images. * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). @@ -86,27 +92,19 @@ class MediaPreProcessorImpl @Inject constructor( override suspend fun process( uri: Uri, - mediaType: MediaType, + mimeType: String, deleteOriginal: Boolean, + compressIfPossible: Boolean, ): Result = runCatching { - // Camera returns an 'octet-stream' mimetype, so it needs to be overridden - val mimeType = contentResolver.getType(uri) - val mimeTypeOrDefault = if (mimeType == MimeTypes.OctetStream) { - when(mediaType) { - MediaType.Image -> MimeTypes.Jpeg - MediaType.Video -> MimeTypes.Mp4 - MediaType.Audio -> MimeTypes.Ogg - else -> mimeType - } - } else { - mimeType - } - val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video) - val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) { - when(mediaType) { - MediaType.Image -> processImage(uri) - MediaType.Video -> processVideo(uri, mimeTypeOrDefault) - MediaType.Audio -> processAudio(uri, mimeTypeOrDefault) + val shouldBeCompressed = compressIfPossible && + (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) || + mimeType.isMimeTypeVideo() + + val result = if (shouldBeCompressed) { + when { + mimeType.isMimeTypeImage() -> processImage(uri) + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) + mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) else -> error("Cannot compress file of type: $mimeType") } } else { @@ -119,16 +117,29 @@ class MediaPreProcessorImpl @Inject constructor( mimetype = mimeType, size = file.length(), thumbnailInfo = null, - thumbnailUrl = null, + thumbnailSource = null, ) MediaUploadInfo.AnyFile(file, info) } - if (deleteOriginal) { - contentResolver.delete(uri, null, null) + tryOrNull { + contentResolver.delete(uri, null, null) + } } + result.postProcess(uri) + }.mapFailure { MediaPreProcessor.Failure(it) } - result + private fun MediaUploadInfo.postProcess(uri: Uri): MediaUploadInfo { + val name = context.getFileName(uri) ?: return this + val renamedFile = File(context.cacheDir, name).also { + file.renameTo(it) + } + return when (this) { + is MediaUploadInfo.AnyFile -> copy(file = renamedFile) + is MediaUploadInfo.Audio -> copy(file = renamedFile) + is MediaUploadInfo.Image -> copy(file = renamedFile) + is MediaUploadInfo.Video -> copy(file = renamedFile) + } } private suspend fun processImage(uri: Uri): MediaUploadInfo { @@ -181,7 +192,6 @@ class MediaPreProcessorImpl @Inject constructor( inputStream = inputStream, resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), ).getOrThrow() - return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg) } @@ -196,7 +206,7 @@ class MediaPreProcessorImpl @Inject constructor( } private suspend fun createTmpFileWithInput(inputStream: InputStream): File? { - return withContext(Dispatchers.IO) { + return withContext(coroutineDispatchers.io) { tryOrNull { val tmpFile = context.createTmpFile() tmpFile.outputStream().use { inputStream.copyTo(it) } @@ -208,7 +218,6 @@ class MediaPreProcessorImpl @Inject constructor( private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): VideoInfo = MediaMetadataRetriever().runAndRelease { setDataSource(context, Uri.fromFile(file)) - VideoInfo( duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L, width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L, @@ -216,7 +225,7 @@ class MediaPreProcessorImpl @Inject constructor( mimetype = mimeType, size = file.length(), thumbnailInfo = thumbnailInfo?.info, - thumbnailUrl = thumbnailUrl, + thumbnailSource = thumbnailUrl?.let { MediaSource(it) }, blurhash = thumbnailInfo?.blurhash, ) } @@ -234,7 +243,6 @@ class MediaPreProcessorImpl @Inject constructor( inputStream = inputStream, resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), ) - result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg) } @@ -250,7 +258,7 @@ fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?, mimetype = mimeType, size = size, thumbnailInfo = thumbnailInfo, - thumbnailUrl = thumbnailUrl, + thumbnailSource = thumbnailUrl?.let { MediaSource(it) }, blurhash = blurhash, ) diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt index 08a284af6c..c4ab8d57b1 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.mediaupload.test import android.net.Uri import io.element.android.libraries.matrix.api.media.FileInfo 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 java.io.File @@ -32,11 +31,17 @@ class FakeMediaPreProcessor : MediaPreProcessor { mimetype = "*/*", size = 999L, thumbnailInfo = null, - thumbnailUrl = null, + thumbnailSource = null, ) ) ) - override suspend fun process(uri: Uri, mediaType: MediaType, deleteOriginal: Boolean): Result = result + + override suspend fun process( + uri: Uri, + mimeType: String, + deleteOriginal: Boolean, + compressIfPossible: Boolean + ): Result = result fun givenResult(value: Result) { this.result = value diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt index a5f72acfb9..b8e3b28dad 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.push.impl.notifications.factories.action +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -70,6 +71,8 @@ class QuickReplyActionFactory @Inject constructor( * However, for Android devices running Marshmallow and below (API level 23 and below), * it will be more appropriate to use an activity. Since you have to provide your own UI. */ + //TODO remove when minSdk will be back to 23 + @SuppressLint("ObsoleteSdkInt") private fun buildQuickReplyIntent( sessionId: SessionId, roomId: RoomId, diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 36a3a33e70..d7c60d7fb0 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -23,7 +23,7 @@ object Versions { const val compileSdk = 33 const val targetSdk = 33 - const val minSdk = 23 + const val minSdk = 24 val javaCompileVersion = JavaVersion.VERSION_17 val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11) } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd4deb3d0b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:372f239e351215dadf0d5c451b8105e93bc86e338f4e564aa4726d482028ae9c +size 396027 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5183011e59 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:087ef6e2e489e63e1a30793b183915984a32265676fd5a95f02d7ca02821c84b +size 132174 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..62f356b12c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5e03b31f020201e8a8bd7ad5962022337441c4e83d15e03e95f83c9fa10eaaf +size 98743 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4afab509fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479 +size 393620 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4afab509fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479 +size 393620 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4afab509fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479 +size 393620 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4afab509fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479 +size 393620 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4afab509fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479 +size 393620 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.textcomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.textcomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.textcomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.textcomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8999b2ceb3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82871e8bf4472e48dd134d55c4ef24a490643e022da3101342fa47ca89024adb +size 7323 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7b849e9cc7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3173576742c91fea1f6825a83e08c85636d14cd6ef9d84ff9c402df7d62eee9 +size 9936 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f529217b49 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ed4bfb88bb5d1a037d0393caf056cb30c23df14a6258dac5b7cdfd2435e6ddf +size 12614 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7b6894cad6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:764bbe03b664b697f2e1bbb93ef879060819b41d8b9f8620ad7f64883c5892c2 +size 6890 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..963b2e8c9d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dbc148e817289cc726fe4fbbba09c0a2b43e794bde2673841ad9062ebfc3ec5 +size 8979 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5d1436751d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5207aa4f89d0613937318de8cf6e324c19ab4b769eca0519f375ab505687b4c +size 11256 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index a8e0956882..b1badf5a3f 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e7b963daafe38fbf25c66d887c617820f2937e0f5467c04abee1458db722e29 -size 263224 +oid sha256:fa17dd70b3c5eaaa37fbc5eed53997dd627090d9a5a3ffa518b9688647449a8e +size 99065 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 38a04f04fd..f5ff66af17 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:713bc925e6075a16b7a95d1083ead3b0230628f75c80cc911e3498544916edfd -size 262092 +oid sha256:4593d265265cea31c20b545250d20df54dfcc877d6e54eae28d1e84a1c693f16 +size 147443 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 8f7de81ddb..db705106e0 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb7b807522be4a1d634935e9d3ffe2aa75fdcd143ff2c43df0c129133cab21d9 -size 335599 +oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402 +size 137137 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 0f5de9a5cc..b1badf5a3f 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3919408521a387dae59941c1579fa5c5010b02262b3fcc1cde4ce7ad1d27b25 -size 262071 +oid sha256:fa17dd70b3c5eaaa37fbc5eed53997dd627090d9a5a3ffa518b9688647449a8e +size 99065 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 90dd3434b4..f5ff66af17 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae843ae142724e84ab66262247091a850f5e51684b8c9d3bb234a4c04ab3edd9 -size 262173 +oid sha256:4593d265265cea31c20b545250d20df54dfcc877d6e54eae28d1e84a1c693f16 +size 147443 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index de08b33e33..db705106e0 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a22b82aa32dd0a246c96a50a0acf68270d0b2b19e78b79c6f817dba510ab81db -size 335888 +oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402 +size 137137 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7407445780 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4e329e21d49bd79b633913edbbc1d5c63024874d866d96d399b1a8ffa5c1f18 +size 99209 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c803700190 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5eb1c967db078a0333ef3fe26f94692264d0158d74d78768df32cc7c641faee3 +size 147537 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da8a3d6499 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d +size 137535 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7407445780 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4e329e21d49bd79b633913edbbc1d5c63024874d866d96d399b1a8ffa5c1f18 +size 99209 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c803700190 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5eb1c967db078a0333ef3fe26f94692264d0158d74d78768df32cc7c641faee3 +size 147537 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da8a3d6499 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d +size 137535 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 660e7604ab..ac79e58737 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8bd4c1be83610c41da8e95d5a2bcd141e513f62ca7223075256149b8c7bb9d25 -size 41915 +oid sha256:0e7e30c460d75815023ed02cd6b0fcff4a15881916f0056e47d9fd034bf3a181 +size 41125 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 636c9994b1..8ca1e75687 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:180265714b5b0a370899468d9ba0f08e4e7d5d3af0721585ad579a643c00c04a -size 53963 +oid sha256:81b126a3d322d70f06db15622e560a146cea2c81f5873987550aba0a5c5efffa +size 53314 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2a2d363120 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7e8b8ff0aeeb517c47dfd987579b73a7e862e9a67a5100532f39087d42a4fe4 +size 46056 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 6dac84acea..cbe31a3f09 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d55aeab4c3a468e089bf6745439af64c89cab7e12f035c28bea4c87bf529c2d -size 43773 +oid sha256:b0ede4470399e2c7e6f3c99e59191a857c1e14254789307777359802980c59a1 +size 193735 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index f06286554d..809b1bba99 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecb6ebe1b17c5be36fe555397afe6ad1c1c93d8c63fca6ab95c49392088bd789 -size 55702 +oid sha256:c9fe22528aad68a5dffc8489fd1ec3138a8edde44fb23a1391f6e53a07d44fff +size 193966 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png index 10556ffeb0..fc4cbf27fd 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e4c9a4df13510e16422822ac4711e87730494d8344581c54853722326eded8e -size 39957 +oid sha256:d559a454af6dfe367d1256dcd7ceeb32ae6fe168b76b1b5d8860d328e9752fb3 +size 51433 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png index b13f04db41..cd486afaf0 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:345d658072f5be4c0a4f42dfabff1e8c64be23810a04acca44984751f59c1c67 -size 56913 +oid sha256:1e45ec17c6353f96f0f777f8c638da010dbd062c680175d8ae80f6e700a6f928 +size 72941 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png index 7b5446c10a..bd287148b7 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:163f0aaf7ea69149daab0dc23d73ae137e39274c1b07b2376a3699c9239e3716 -size 46585 +oid sha256:572d6f11354e2b1f02cb0979b35e2c798abe2d3b63f852ca6b868471ceecaed0 +size 42964 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..842ea61125 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b25f0497c08515125896756c89c5bc9e1757b25ec50478cd5cb67cdfd00c2dec +size 55019 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d83002fedd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f22479bb2c6bd35c6f927c11ff19e644226df3d2a99b9c6b23f5bd679178d5a +size 39151 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cc3b898a92 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6920b50647111a70215c74ca27be736d6c208c0801eb4bcd323abcb33e903aea +size 56229 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 410bcded08..8a2c64bd25 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:02f7a5ee81caf8fb81af1f2d5a4e98dfadf59e279a7814e177f657a68400110e -size 41256 +oid sha256:a01bde6db3ce00e84e9bb5d6a8179c8d97af9dde939f6e31aa81c955dbdbf3a3 +size 40586 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 4202b21562..a07f4eb8ed 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f761a4cb835fdf706588d33463989dca4fd944282a24c2e78cf4c166032d8b53 -size 53778 +oid sha256:3f12a13dd0fb3b569ce332ebc4e00fb55a6402bbf2b2a927b02abb4056c5e5c3 +size 53043 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bdb8e46417 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c50bb17bf530de6472d44232b0248c2a5761c67fde785b3aee6eb3f77fa4103e +size 45948 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index d1433e4390..cdfd4fd6be 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34a6cf95648e65d65d6e30a1b16dddc4db46c7d2c5d003f2fc5a39f3ac2fe3c2 -size 43041 +oid sha256:c0f6c785a1e20160355c72356744dedba65bda3d453fbd9772390350eaf6cc6e +size 195504 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png index 71a4990cfb..cb6574b5ad 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c6cb3ef8bf58dc6ce169c384cf965df98b7a3cb758e05b572f4fd08eb5269a0d -size 55635 +oid sha256:37c702936170a0e4b37d608cf62b92c3b60b93950d4e9f32633af77e3ce4fdc9 +size 195775 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png index bb9312bc57..949cd3ef67 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9b2110cffc15e33aa935b2f642c70c53a954f77603557a38b50e06c4776daf8 -size 39287 +oid sha256:e0b885810087916d3fefe699e5abc5125138ad74c6377f560e010e7fe68892da +size 51388 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png index d681ab46e1..6a5e4d9ec6 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90a81e0b8dc1beef0d82ac515e2dc3065505924bb4124517e7fd459383bc2022 -size 56962 +oid sha256:adcb71f4b9639287beabf35c9e91837bebee8f6b070fa77c0e6520fdf24faaae +size 73679 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png index ca34066989..07823941c7 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53814425322ccf473284344df138c8894e520d10be2bbd9ba857cee758b36319 -size 46570 +oid sha256:cb24bbba2e17417f4f3459353ee2c762517acfae2eada90c47b559828bad16a0 +size 42411 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..61aaf14dac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d75c877c55c0f622aabee5ed8428b8a980cbc28daacb2127f2a17cc29db62a9d +size 55057 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..97d47f37f0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6edb33e04054aba49ea978d6b2c0d0c25332c4dd901cf2cadd6c368076e957d +size 38687 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4bca8aaed3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996016191220540fde3d8aaa3124a074a6eaaf6e87c43bf0d57d32992faf47fc +size 56342 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 09d47863c5..3884544dd1 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49f7df48099566151474d1a3183dcc72359cf0b6a9bb5d38b15bd86ead6f9fde -size 44390 +oid sha256:9c2c642f1595f503a1deb1c241f14543d7f15221baa1d0937595aa023f36a874 +size 44765 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 7d61515e2d..0dd33fab8e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51f47e0b2ed979001dd76e440f8620f90ac42410999565a4341dad96bc5ecfbc -size 44924 +oid sha256:1afd2c42012017f154fdf031d4a5fc7384fa5f239b56cf4567f42fd579806c9a +size 45363 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index dc86130022..a934e0c73e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d24be9c801fd9d81df51f8d7d7acf416a314d139164a81efb2ba9734b1ab8b37 -size 41870 +oid sha256:9481f2994885a7649aa54d1c0043c44fe390004a7b58db4fc9d0e3e70bfc258a +size 43537 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index c4b0eaa7bc..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9d8cb1f4a11abbd47e86e575165e15e79f3e9385b7876e2348949a5553237b5f -size 40671 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 580912dff4..a32dc0d376 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a551da4c372401e8337d3ae94d541f3a02dbc02ae17109705f8ca4cf1f343ba4 -size 43496 +oid sha256:220b9ac66b74724887d7b0bbc082b4d0240d9fc25a068d33491eb83fe76e45fd +size 43958 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index fed5f79eb9..6c69a3850e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74b45b6e8fdfca3307bad4506f47b47f3ccafbc434142a53b275152af050c86b -size 44276 +oid sha256:9aa83ed14fb23495e79500a1e7d9dee8215efe67eec89e65e382d4f695eac68b +size 44871 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 7d1fcc6b78..0be594f38f 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ecbf75680c3c1b21ecb6453180f2817532c7ecc59d494243527effb06bed727 -size 40967 +oid sha256:aa4970f995c6ee618be3d868ff5a34442352a7f4493cad345093b889daf0d4c2 +size 42042 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index 2c1d31e9c0..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ea5e469f454f50b88bc2d2860f3271119b1b85bacd8afe7210cb8ce38b44252c -size 40019