From c0470d4bb58deca84644184bad0675216d41881a Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 1 Jun 2023 22:01:05 +0200 Subject: [PATCH 01/32] Media viewer: start adding save on disk action --- .../local/AndroidLocalMediaActionsHandler.kt | 94 +++++++++++++++++++ .../media/local/LocalMediaActionsHandler.kt | 23 +++++ .../impl/media/viewer/MediaViewerEvents.kt | 1 + .../impl/media/viewer/MediaViewerNode.kt | 3 +- .../impl/media/viewer/MediaViewerPresenter.kt | 11 +++ .../impl/media/viewer/MediaViewerView.kt | 38 +++++++- 6 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActionsHandler.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt new file mode 100644 index 0000000000..7b619ab419 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt @@ -0,0 +1,94 @@ +/* + * 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.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidLocalMediaActionsHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val coroutineDispatchers: CoroutineDispatchers, +) : LocalMediaActionsHandler { + + override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveOnDiskUsingMediaStore(localMedia) + } else { + saveOnDiskUsingExternalStorageApi(localMedia) + } + } + } + + override suspend fun share(localMedia: LocalMedia): Result { + TODO("Not yet implemented") + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.name) + put(MediaStore.MediaColumns.MIME_TYPE, localMedia.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + if (uri != null) { + localMedia.openStream(resolver)?.use { input -> + resolver.openOutputStream(uri).use { output -> + input.copyTo(output!!, DEFAULT_BUFFER_SIZE) + } + } + } + } + + private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) { + val target = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + localMedia.name ?: "" + ) + localMedia.openStream(context.contentResolver)?.use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + } + } + } + + private fun LocalMedia.openStream(contentResolver: ContentResolver): InputStream? { + return when (val model = model) { + is File -> model.inputStream() + is Uri -> contentResolver.openInputStream(model) + else -> null + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActionsHandler.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActionsHandler.kt new file mode 100644 index 0000000000..7af7110b70 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActionsHandler.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.media.local + +interface LocalMediaActionsHandler { + suspend fun saveOnDisk(localMedia: LocalMedia): Result + suspend fun share(localMedia: LocalMedia): Result +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt index b0bbad5ec2..030ee6b269 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.media.viewer sealed interface MediaViewerEvents { + object SaveOnDisk: 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 index 247a86263f..ee1bb50ce9 100644 --- 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 @@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor( val state = presenter.present() MediaViewerView( state = state, - modifier = modifier + modifier = modifier, + onBackPressed = this::navigateUp ) } } 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 index fb563461c5..145e9607de 100644 --- 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 @@ -28,6 +28,7 @@ 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.LocalMediaActionsHandler import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter @@ -40,6 +41,7 @@ class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerNode.Inputs, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, + private val mediaActionsHandler: LocalMediaActionsHandler, ) : Presenter { @AssistedFactory @@ -68,6 +70,7 @@ class MediaViewerPresenter @AssistedInject constructor( when (mediaViewerEvents) { MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized + MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) } } @@ -93,4 +96,12 @@ class MediaViewerPresenter @AssistedInject constructor( localMedia.value = Async.Failure(it) } } + + private fun CoroutineScope.saveOnDisk(value: Async) = launch { + when (value) { + is Async.Success -> mediaActionsHandler.saveOnDisk(value.state) + else -> Unit + } + } } + 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 index ae598688d1..f821f488c9 100644 --- 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 @@ -14,6 +14,7 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) package io.element.android.features.messages.impl.media.viewer @@ -23,6 +24,10 @@ 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.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -39,19 +44,25 @@ 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.button.BackButton 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.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.R.* import kotlinx.coroutines.delay import io.element.android.libraries.ui.strings.R as StringR @Composable fun MediaViewerView( state: MediaViewerState, + onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { @@ -85,7 +96,11 @@ fun MediaViewerView( showThumbnail = false } - Scaffold(modifier) { + Scaffold(modifier, + topBar = { + MediaViewerTopBar(onBackPressed, state.eventSink) + } + ) { Box( modifier = Modifier .fillMaxSize() @@ -113,6 +128,26 @@ fun MediaViewerView( } } +@Composable +private fun MediaViewerTopBar( + onBackPressed: () -> Unit, + eventSink: (MediaViewerEvents) -> Unit, +) { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + IconButton( + onClick = { + eventSink(MediaViewerEvents.SaveOnDisk) + }, + ) { + Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = string.action_save)) + } + } + ) +} + @Composable private fun ThumbnailView( thumbnailSource: MediaSource?, @@ -175,5 +210,6 @@ fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class private fun ContentToPreview(state: MediaViewerState) { MediaViewerView( state = state, + onBackPressed = {} ) } From ab48735ddc91098f518f00af1b888dbe1dbf53d3 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 1 Jun 2023 22:35:17 +0200 Subject: [PATCH 02/32] Media: refactor LocalMedia so the source of data is clear (uri or file) --- .../preview/AttachmentsPreviewPresenter.kt | 9 +++- .../AttachmentsPreviewStateProvider.kt | 4 +- .../media/local/AndroidLocalMediaFactory.kt | 16 ++++-- .../messages/impl/media/local/LocalMedia.kt | 19 ++++--- .../impl/media/local/LocalMediaView.kt | 9 ++-- .../impl/media/local/UriToFileMapper.kt | 51 ------------------- .../media/local/exoplayer/LocalMediaExt.kt | 27 ++++++++++ .../media/viewer/MediaViewerStateProvider.kt | 6 +-- .../MessageComposerPresenter.kt | 11 ++-- .../features/messages/fixtures/media.kt | 3 +- 10 files changed, 77 insertions(+), 78 deletions(-) delete mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/LocalMediaExt.kt 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 index d80359e88c..537d259599 100644 --- 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 @@ -25,6 +25,7 @@ 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.features.messages.impl.media.local.LocalMedia import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.executeResult @@ -84,7 +85,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( sendActionState: MutableState>, ) { suspend { - mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) + when (mediaAttachment.localMedia.source) { + is LocalMedia.Source.FromUri -> { + mediaSender.sendMedia(mediaAttachment.localMedia.source.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) + } + else -> error("Attachment should be defined by a uri") + } + }.executeResult(sendActionState) } } 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 index 26565a226a..e7a3968386 100644 --- 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 @@ -17,11 +17,11 @@ 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 +import java.io.File open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence @@ -34,7 +34,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider = Async.Uninitialized) = AttachmentsPreviewState( attachment = Attachment.Media( - localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L), + localMedia = LocalMedia(LocalMedia.Source.FromFile(File("path")), MimeTypes.Jpeg, "an image", 1000L), compressIfPossible = true ), sendActionState = sendActionState, 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 index bc2f1a066c..4ee00914ed 100644 --- 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 @@ -18,7 +18,6 @@ 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 @@ -26,6 +25,7 @@ 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 java.io.File import javax.inject.Inject @ContributesBinding(AppScope::class) @@ -34,8 +34,16 @@ class AndroidLocalMediaFactory @Inject constructor( ) : LocalMediaFactory { override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { - val uri = mediaFile.path().toUri() - return createFromUri(uri, mimeType) + val resolvedMimeType = mimeType ?: MimeTypes.OctetStream + val file = File(mediaFile.path()) + val fileName = file.name + val fileSize = file.length() + return LocalMedia( + source = LocalMedia.Source.FromFile(file), + mimeType = resolvedMimeType, + name = fileName, + size = fileSize + ) } override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { @@ -43,7 +51,7 @@ class AndroidLocalMediaFactory @Inject constructor( val fileName = context.getFileName(uri) val fileSize = context.getFileSize(uri) return LocalMedia( - uri = uri, + source = LocalMedia.Source.FromUri(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 index 8305c8eee7..5d1afd6bec 100644 --- 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 @@ -21,20 +21,27 @@ import android.os.Parcelable import androidx.compose.runtime.Immutable import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import java.io.File @Parcelize @Immutable data class LocalMedia( - val uri: Uri, + val source: Source, 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 + sealed interface Source : Parcelable { + @Parcelize + data class FromUri(val uri: Uri) : Source + + @Parcelize + data class FromFile(val file: File) : Source + } + + @IgnoredOnParcel val model: Any = when (source) { + is Source.FromUri -> source.uri + is Source.FromFile -> source.file } } 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 index 3040f0bfbd..c1d9e2f119 100644 --- 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 @@ -31,13 +31,13 @@ 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.features.messages.impl.media.local.exoplayer.toMediaItem import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import me.saket.telephoto.zoomable.ZoomSpec @@ -107,6 +107,7 @@ fun MediaVideoView( onReady: () -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val playerListener = object : Player.Listener { override fun onRenderedFirstFrame() { @@ -120,9 +121,9 @@ fun MediaVideoView( this.prepare() } } - if (localMedia?.uri != null) { - LaunchedEffect(localMedia.uri) { - val mediaItem = MediaItem.fromUri(localMedia.uri) + if (localMedia?.source != null) { + LaunchedEffect(localMedia.source) { + val mediaItem = localMedia.toMediaItem() exoPlayer.setMediaItem(mediaItem) } } else { 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 deleted file mode 100644 index d27b667883..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/UriToFileMapper.kt +++ /dev/null @@ -1,51 +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.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/LocalMediaExt.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/LocalMediaExt.kt new file mode 100644 index 0000000000..9a96a6a39b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/LocalMediaExt.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.features.messages.impl.media.local.exoplayer + +import androidx.media3.common.MediaItem +import io.element.android.features.messages.impl.media.local.LocalMedia + +fun LocalMedia.toMediaItem(): MediaItem { + return when (source) { + is LocalMedia.Source.FromFile -> MediaItem.fromUri(source.file.path) + is LocalMedia.Source.FromUri -> MediaItem.fromUri(source.uri) + } +} 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 index 6c54eb3b05..c961f10caf 100644 --- 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 @@ -16,11 +16,11 @@ 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 +import java.io.File open class MediaViewerStateProvider : PreviewParameterProvider { override val values: Sequence @@ -31,14 +31,14 @@ open class MediaViewerStateProvider : PreviewParameterProvider aMediaViewerState( Async.Success( LocalMedia( - Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L + LocalMedia.Source.FromFile(File("")), MimeTypes.IMAGE_JPEG, "an image file", 100L ) ), ), aMediaViewerState( Async.Success( LocalMedia( - Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L + LocalMedia.Source.FromFile(File("")), MimeTypes.VIDEO_MP4, "a video file", 100L ) ), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index ae975f4ddb..74f533c6fe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -31,6 +31,7 @@ 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.LocalMedia 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 @@ -191,8 +192,7 @@ class MessageComposerPresenter @Inject constructor( when (attachment) { is Attachment.Media -> { sendMedia( - uri = attachment.localMedia.uri, - mimeType = attachment.localMedia.mimeType, + media = attachment.localMedia, attachmentState = attachmentState ) } @@ -226,11 +226,12 @@ class MessageComposerPresenter @Inject constructor( } private suspend fun sendMedia( - uri: Uri, - mimeType: String, + media: LocalMedia, attachmentState: MutableState, ) { - mediaSender.sendMedia(uri, mimeType, compressIfPossible = false) + if (media.source !is LocalMedia.Source.FromUri) error("Attachment should use Uri") + val uri = media.source.uri + mediaSender.sendMedia(uri, media.mimeType, compressIfPossible = false) .onSuccess { attachmentState.value = AttachmentsState.None }.onFailure { 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 index 60a01a76d4..399c39cc88 100644 --- 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 @@ -20,7 +20,6 @@ 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, @@ -28,7 +27,7 @@ fun aLocalMedia( name: String = "a media", size: Long = 1000, ) = LocalMedia( - uri = uri, + source = LocalMedia.Source.FromUri(uri), mimeType = mimeType, name = name, size = size, From 70e9dfa25d61bfc3a46bbd510c1ea5fbecb14f5d Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 08:49:28 +0200 Subject: [PATCH 03/32] Media: finally revert to using only uri but with the proper scheme.. --- .../preview/AttachmentsPreviewPresenter.kt | 9 +------ .../AttachmentsPreviewStateProvider.kt | 4 +-- .../local/AndroidLocalMediaActionsHandler.kt | 20 +++++--------- .../media/local/AndroidLocalMediaFactory.kt | 17 ++++-------- .../messages/impl/media/local/LocalMedia.kt | 19 ++----------- .../impl/media/local/LocalMediaView.kt | 11 ++++---- .../media/local/exoplayer/LocalMediaExt.kt | 27 ------------------- .../media/viewer/MediaViewerStateProvider.kt | 6 ++--- .../MessageComposerPresenter.kt | 11 ++++---- .../features/messages/fixtures/media.kt | 3 ++- .../libraries/matrix/api/media/MediaFile.kt | 5 ++++ 11 files changed, 37 insertions(+), 95 deletions(-) delete mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/LocalMediaExt.kt 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 index 537d259599..d80359e88c 100644 --- 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 @@ -25,7 +25,6 @@ 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.features.messages.impl.media.local.LocalMedia import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.executeResult @@ -85,13 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( sendActionState: MutableState>, ) { suspend { - when (mediaAttachment.localMedia.source) { - is LocalMedia.Source.FromUri -> { - mediaSender.sendMedia(mediaAttachment.localMedia.source.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) - } - else -> error("Attachment should be defined by a uri") - } - + 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/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index e7a3968386..26565a226a 100644 --- 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 @@ -17,11 +17,11 @@ 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 -import java.io.File open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence @@ -34,7 +34,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider = Async.Uninitialized) = AttachmentsPreviewState( attachment = Attachment.Media( - localMedia = LocalMedia(LocalMedia.Source.FromFile(File("path")), MimeTypes.Jpeg, "an image", 1000L), + localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L), compressIfPossible = true ), sendActionState = sendActionState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt index 7b619ab419..1e3056e146 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt @@ -16,10 +16,8 @@ package io.element.android.features.messages.impl.media.local -import android.content.ContentResolver import android.content.ContentValues import android.content.Context -import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore @@ -62,10 +60,10 @@ class AndroidLocalMediaActionsHandler @Inject constructor( put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } val resolver = context.contentResolver - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) - if (uri != null) { - localMedia.openStream(resolver)?.use { input -> - resolver.openOutputStream(uri).use { output -> + val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + if (outputUri != null) { + localMedia.openStream()?.use { input -> + resolver.openOutputStream(outputUri).use { output -> input.copyTo(output!!, DEFAULT_BUFFER_SIZE) } } @@ -77,18 +75,14 @@ class AndroidLocalMediaActionsHandler @Inject constructor( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), localMedia.name ?: "" ) - localMedia.openStream(context.contentResolver)?.use { input -> + localMedia.openStream()?.use { input -> FileOutputStream(target).use { output -> input.copyTo(output) } } } - private fun LocalMedia.openStream(contentResolver: ContentResolver): InputStream? { - return when (val model = model) { - is File -> model.inputStream() - is Uri -> contentResolver.openInputStream(model) - else -> null - } + private fun LocalMedia.openStream(): InputStream? { + return context.contentResolver.openInputStream(uri) } } 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 index 4ee00914ed..68c1b935d8 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -25,7 +26,7 @@ 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 java.io.File +import io.element.android.libraries.matrix.api.media.toFile import javax.inject.Inject @ContributesBinding(AppScope::class) @@ -34,16 +35,8 @@ class AndroidLocalMediaFactory @Inject constructor( ) : LocalMediaFactory { override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { - val resolvedMimeType = mimeType ?: MimeTypes.OctetStream - val file = File(mediaFile.path()) - val fileName = file.name - val fileSize = file.length() - return LocalMedia( - source = LocalMedia.Source.FromFile(file), - mimeType = resolvedMimeType, - name = fileName, - size = fileSize - ) + val uri = mediaFile.toFile().toUri() + return createFromUri(uri, mimeType) } override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { @@ -51,7 +44,7 @@ class AndroidLocalMediaFactory @Inject constructor( val fileName = context.getFileName(uri) val fileSize = context.getFileSize(uri) return LocalMedia( - source = LocalMedia.Source.FromUri(uri), + 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 index 5d1afd6bec..e125e531bf 100644 --- 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 @@ -21,27 +21,12 @@ import android.os.Parcelable import androidx.compose.runtime.Immutable import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import java.io.File @Parcelize @Immutable data class LocalMedia( - val source: Source, + val uri: Uri, val mimeType: String, val name: String?, val size: Long, -) : Parcelable { - - sealed interface Source : Parcelable { - @Parcelize - data class FromUri(val uri: Uri) : Source - - @Parcelize - data class FromFile(val file: File) : Source - } - - @IgnoredOnParcel val model: Any = when (source) { - is Source.FromUri -> source.uri - is Source.FromFile -> source.file - } -} +) : Parcelable 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 index c1d9e2f119..4a4bc5c9fc 100644 --- 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 @@ -31,13 +31,13 @@ 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.features.messages.impl.media.local.exoplayer.toMediaItem import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import me.saket.telephoto.zoomable.ZoomSpec @@ -93,7 +93,7 @@ private fun MediaImageView( ZoomableAsyncImage( modifier = modifier.fillMaxSize(), state = zoomableImageState, - model = localMedia?.model, + model = localMedia?.uri, contentDescription = "Image", contentScale = ContentScale.Fit, ) @@ -107,7 +107,6 @@ fun MediaVideoView( onReady: () -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val playerListener = object : Player.Listener { override fun onRenderedFirstFrame() { @@ -121,9 +120,9 @@ fun MediaVideoView( this.prepare() } } - if (localMedia?.source != null) { - LaunchedEffect(localMedia.source) { - val mediaItem = localMedia.toMediaItem() + if (localMedia?.uri != null) { + LaunchedEffect(localMedia.uri) { + val mediaItem = MediaItem.fromUri(localMedia.uri) exoPlayer.setMediaItem(mediaItem) } } else { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/LocalMediaExt.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/LocalMediaExt.kt deleted file mode 100644 index 9a96a6a39b..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/LocalMediaExt.kt +++ /dev/null @@ -1,27 +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.features.messages.impl.media.local.exoplayer - -import androidx.media3.common.MediaItem -import io.element.android.features.messages.impl.media.local.LocalMedia - -fun LocalMedia.toMediaItem(): MediaItem { - return when (source) { - is LocalMedia.Source.FromFile -> MediaItem.fromUri(source.file.path) - is LocalMedia.Source.FromUri -> MediaItem.fromUri(source.uri) - } -} 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 index c961f10caf..6c54eb3b05 100644 --- 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 @@ -16,11 +16,11 @@ 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 -import java.io.File open class MediaViewerStateProvider : PreviewParameterProvider { override val values: Sequence @@ -31,14 +31,14 @@ open class MediaViewerStateProvider : PreviewParameterProvider aMediaViewerState( Async.Success( LocalMedia( - LocalMedia.Source.FromFile(File("")), MimeTypes.IMAGE_JPEG, "an image file", 100L + Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L ) ), ), aMediaViewerState( Async.Success( LocalMedia( - LocalMedia.Source.FromFile(File("")), MimeTypes.VIDEO_MP4, "a video file", 100L + Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L ) ), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 74f533c6fe..ae975f4ddb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -31,7 +31,6 @@ 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.LocalMedia 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 @@ -192,7 +191,8 @@ class MessageComposerPresenter @Inject constructor( when (attachment) { is Attachment.Media -> { sendMedia( - media = attachment.localMedia, + uri = attachment.localMedia.uri, + mimeType = attachment.localMedia.mimeType, attachmentState = attachmentState ) } @@ -226,12 +226,11 @@ class MessageComposerPresenter @Inject constructor( } private suspend fun sendMedia( - media: LocalMedia, + uri: Uri, + mimeType: String, attachmentState: MutableState, ) { - if (media.source !is LocalMedia.Source.FromUri) error("Attachment should use Uri") - val uri = media.source.uri - mediaSender.sendMedia(uri, media.mimeType, compressIfPossible = false) + mediaSender.sendMedia(uri, mimeType, compressIfPossible = false) .onSuccess { attachmentState.value = AttachmentsState.None }.onFailure { 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 index 399c39cc88..60a01a76d4 100644 --- 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 @@ -20,6 +20,7 @@ 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, @@ -27,7 +28,7 @@ fun aLocalMedia( name: String = "a media", size: Long = 1000, ) = LocalMedia( - source = LocalMedia.Source.FromUri(uri), + uri = uri, mimeType = mimeType, name = name, size = size, 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 index 3ef659133d..d4989dbffc 100644 --- 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 @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.media import java.io.Closeable +import java.io.File /** * A wrapper around a media file on the disk. @@ -25,3 +26,7 @@ import java.io.Closeable interface MediaFile : Closeable { fun path(): String } + +fun MediaFile.toFile(): File { + return File(path()) +} From 80b410860fd02d655aa7343db2e0285469068fcb Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 09:44:07 +0200 Subject: [PATCH 04/32] Media: improve creation of LocalMedia --- .../media/local/AndroidLocalMediaFactory.kt | 16 ++++--------- .../impl/media/local/LocalMediaFactory.kt | 23 ++++++++++++++----- .../impl/media/viewer/MediaViewerPresenter.kt | 9 ++++++-- .../MessageComposerPresenter.kt | 2 +- .../messages/media/FakeLocalMediaFactory.kt | 6 +---- .../libraries/androidutils/file/Context.kt | 13 ++++++++--- 6 files changed, 41 insertions(+), 28 deletions(-) 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 index 68c1b935d8..4908d79f68 100644 --- 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 @@ -18,15 +18,13 @@ 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.androidutils.file.getMimeType 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 io.element.android.libraries.matrix.api.media.toFile import javax.inject.Inject @ContributesBinding(AppScope::class) @@ -34,14 +32,9 @@ class AndroidLocalMediaFactory @Inject constructor( @ApplicationContext private val context: Context ) : LocalMediaFactory { - override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { - val uri = mediaFile.toFile().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) + override fun createFromUri(uri: Uri, mimeType: String?, name: String?): LocalMedia { + val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream + val fileName = name ?: context.getFileName(uri) val fileSize = context.getFileSize(uri) return LocalMedia( uri = uri, @@ -51,3 +44,4 @@ class AndroidLocalMediaFactory @Inject constructor( ) } } + 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 index 09c44f4fba..917369292a 100644 --- 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 @@ -17,18 +17,29 @@ package io.element.android.features.messages.impl.media.local import android.net.Uri +import androidx.core.net.toUri import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.api.media.toFile 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. + * If the [name] is null, it'll try to read it from the content. */ - fun createFromUri(uri: Uri, mimeType: String?): LocalMedia + fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + ): LocalMedia +} + +fun LocalMediaFactory.createFromMediaFile( + mediaFile: MediaFile, + mimeType: String?, + name: String? +): LocalMedia { + val uri = mediaFile.toFile().toUri() + return createFromUri(uri = uri, mimeType = mimeType, name = name) } 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 index 145e9607de..54e2175257 100644 --- 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 @@ -30,6 +30,7 @@ import dagger.assisted.AssistedInject import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaActionsHandler import io.element.android.features.messages.impl.media.local.LocalMediaFactory +import io.element.android.features.messages.impl.media.local.createFromMediaFile import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.media.MatrixMediaLoader @@ -88,8 +89,12 @@ class MediaViewerPresenter @AssistedInject constructor( mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType) .onSuccess { mediaFile.value = it - }.mapCatching { - localMediaFactory.createFromMediaFile(it, inputs.mimeType) + }.mapCatching { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mimeType = inputs.mimeType, + name = inputs.name + ) }.onSuccess { localMedia.value = Async.Success(it) }.onFailure { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index ae975f4ddb..cb966189a0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -210,7 +210,7 @@ class MessageComposerPresenter @Inject constructor( attachmentsState.value = AttachmentsState.None return } - val localMedia = localMediaFactory.createFromUri(uri, mimeType) + val localMedia = localMediaFactory.createFromUri(uri, mimeType, null) val mediaAttachment = Attachment.Media(localMedia, compressIfPossible) val isPreviewable = when { MimeTypes.isImage(localMedia.mimeType) -> true 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 index 89f4e96173..1f7dda5f34 100644 --- 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 @@ -27,11 +27,7 @@ class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory var fallbackMimeType: String = MimeTypes.OctetStream - override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { - return aLocalMedia(uri = localMediaUri, mimeType = mimeType ?: fallbackMimeType) - } - - override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { + override fun createFromUri(uri: Uri, mimeType: String?, name: String?): LocalMedia { return aLocalMedia(uri, mimeType ?: fallbackMimeType) } } 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 index 6bf784b100..b730eec3d5 100644 --- 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 @@ -20,17 +20,24 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import java.io.File +import androidx.core.net.toFile + +fun Context.getMimeType(uri: Uri): String? = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> contentResolver.getType(uri) + else -> null +} fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) - else -> uri.path?.let(::File)?.name + ContentResolver.SCHEME_FILE -> uri.toFile().name + else -> null } fun Context.getFileSize(uri: Uri): Long { return when (uri.scheme) { ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri) - else -> uri.path?.let(::File)?.length() + ContentResolver.SCHEME_FILE -> uri.toFile().length() + else -> 0 } ?: 0 } From 4ab535cf3362514eb7b500ee6b100497735aac90 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 16:43:28 +0200 Subject: [PATCH 05/32] Media: implements share action --- ...Handler.kt => AndroidLocalMediaActions.kt} | 35 ++++++++++++++++--- ...ActionsHandler.kt => LocalMediaActions.kt} | 2 +- .../impl/media/viewer/MediaViewerEvents.kt | 1 + .../impl/media/viewer/MediaViewerPresenter.kt | 20 ++++++++--- .../impl/media/viewer/MediaViewerView.kt | 9 ++++- gradle.properties | 2 +- 6 files changed, 57 insertions(+), 12 deletions(-) rename features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/{AndroidLocalMediaActionsHandler.kt => AndroidLocalMediaActions.kt} (70%) rename features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/{LocalMediaActionsHandler.kt => LocalMediaActions.kt} (95%) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt similarity index 70% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt index 1e3056e146..eb1773042b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActionsHandler.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -18,25 +18,31 @@ package io.element.android.features.messages.impl.media.local import android.content.ContentValues import android.content.Context +import android.content.Intent import android.os.Build import android.os.Environment import android.provider.MediaStore import androidx.annotation.RequiresApi +import androidx.core.content.FileProvider +import androidx.core.net.toFile import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import kotlinx.coroutines.withContext +import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.io.InputStream import javax.inject.Inject @ContributesBinding(AppScope::class) -class AndroidLocalMediaActionsHandler @Inject constructor( +class AndroidLocalMediaActions @Inject constructor( @ApplicationContext private val context: Context, private val coroutineDispatchers: CoroutineDispatchers, -) : LocalMediaActionsHandler { + private val buildMeta: BuildMeta, +) : LocalMediaActions { override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { runCatching { @@ -45,11 +51,28 @@ class AndroidLocalMediaActionsHandler @Inject constructor( } else { saveOnDiskUsingExternalStorageApi(localMedia) } + }.onSuccess { + Timber.v("Save on disk succeed") + }.onFailure { + Timber.e(it, "Save on disk failed") } } - override suspend fun share(localMedia: LocalMedia): Result { - TODO("Not yet implemented") + override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + runCatching { + val authority = "${buildMeta.applicationId}.fileprovider" + val uriFromFileProvider = FileProvider.getUriForFile(context, authority, localMedia.toFile()) + val shareMediaIntent = Intent(Intent.ACTION_VIEW) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + .setDataAndType(uriFromFileProvider, localMedia.mimeType) + withContext(coroutineDispatchers.main) { + context.startActivity(shareMediaIntent, null) + } + }.onSuccess { + Timber.v("Share media succeed") + }.onFailure { + Timber.e(it, "Share media failed") + } } @RequiresApi(Build.VERSION_CODES.Q) @@ -85,4 +108,8 @@ class AndroidLocalMediaActionsHandler @Inject constructor( private fun LocalMedia.openStream(): InputStream? { return context.contentResolver.openInputStream(uri) } + + private fun LocalMedia.toFile(): File { + return uri.toFile() + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActionsHandler.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt similarity index 95% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActionsHandler.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt index 7af7110b70..f7c37bd14a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActionsHandler.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -16,7 +16,7 @@ package io.element.android.features.messages.impl.media.local -interface LocalMediaActionsHandler { +interface LocalMediaActions { suspend fun saveOnDisk(localMedia: LocalMedia): Result suspend fun share(localMedia: LocalMedia): Result } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt index 030ee6b269..375b7e4a34 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.media.viewer sealed interface MediaViewerEvents { object SaveOnDisk: MediaViewerEvents + object Share: MediaViewerEvents object RetryLoading : MediaViewerEvents object ClearLoadingError : MediaViewerEvents } 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 index 54e2175257..b8e175424a 100644 --- 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 @@ -28,7 +28,7 @@ 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.LocalMediaActionsHandler +import io.element.android.features.messages.impl.media.local.LocalMediaActions import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.features.messages.impl.media.local.createFromMediaFile import io.element.android.libraries.architecture.Async @@ -42,7 +42,7 @@ class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerNode.Inputs, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, - private val mediaActionsHandler: LocalMediaActionsHandler, + private val mediaActionsHandler: LocalMediaActions, ) : Presenter { @AssistedFactory @@ -72,6 +72,7 @@ class MediaViewerPresenter @AssistedInject constructor( MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) + MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) } } @@ -102,11 +103,20 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun CoroutineScope.saveOnDisk(value: Async) = launch { - when (value) { - is Async.Success -> mediaActionsHandler.saveOnDisk(value.state) + private fun CoroutineScope.saveOnDisk(localMedia: Async) = launch { + when (localMedia) { + is Async.Success -> mediaActionsHandler.saveOnDisk(localMedia.state) + else -> Unit + } + } + + private fun CoroutineScope.share(localMedia: Async) = launch { + when (localMedia) { + is Async.Success -> mediaActionsHandler.share(localMedia.state) else -> Unit } } } + + 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 index f821f488c9..721cb76350 100644 --- 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 @@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -137,6 +137,13 @@ private fun MediaViewerTopBar( title = {}, navigationIcon = { BackButton(onClick = onBackPressed) }, actions = { + IconButton( + onClick = { + eventSink(MediaViewerEvents.Share) + }, + ) { + Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = string.action_share)) + } IconButton( onClick = { eventSink(MediaViewerEvents.SaveOnDisk) diff --git a/gradle.properties b/gradle.properties index ae25b1ed02..9b4f41f685 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,7 +35,7 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -org.gradle.caching=true +org.gradle.caching=false org.gradle.configureondemand=true org.gradle.parallel=true From 235efeadb6000f7cb5c12a3a03d288bbba76bcdf Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 17:29:24 +0200 Subject: [PATCH 06/32] Pdf : fix after merge --- .../features/messages/impl/media/local/LocalMediaView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f5c67baa5f..28b49cf8ec 100644 --- 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 @@ -176,7 +176,7 @@ fun MediaPDFView( modifier: Modifier = Modifier, ) { val pdfViewerState = rememberPdfViewerState( - model = localMedia?.model, + model = localMedia?.uri, zoomableState = zoomableState ) LaunchedEffect(pdfViewerState.isLoaded) { From c2fa9e6adde53a50297fe425ddb4caddb8cfb80a Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 17:46:48 +0200 Subject: [PATCH 07/32] Media Viewer: rename the shared file with the known name if any. --- .../impl/media/local/AndroidLocalMediaActions.kt | 15 ++++++++++++++- .../impl/media/local/LocalMediaActions.kt | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt index eb1773042b..65cc2e45bc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.media.local +import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.content.Intent @@ -45,6 +46,7 @@ class AndroidLocalMediaActions @Inject constructor( ) : LocalMediaActions { override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { saveOnDiskUsingMediaStore(localMedia) @@ -59,6 +61,7 @@ class AndroidLocalMediaActions @Inject constructor( } override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { val authority = "${buildMeta.applicationId}.fileprovider" val uriFromFileProvider = FileProvider.getUriForFile(context, authority, localMedia.toFile()) @@ -109,7 +112,17 @@ class AndroidLocalMediaActions @Inject constructor( return context.contentResolver.openInputStream(uri) } + /** + * Tries to extract a file from the uri and rename it using the local media name if defined. + */ private fun LocalMedia.toFile(): File { - return uri.toFile() + val uriAsFile = uri.toFile() + return if (name != null) { + File(uriAsFile.parentFile, name).apply { + uriAsFile.renameTo(this) + } + } else { + uriAsFile + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt index f7c37bd14a..03f09488df 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -17,7 +17,16 @@ package io.element.android.features.messages.impl.media.local interface LocalMediaActions { + /** + * Will save the current media to the Downloads directory. + * The [LocalMedia.uri] needs to have a file scheme. + */ suspend fun saveOnDisk(localMedia: LocalMedia): Result + + /** + * Will try to find a suitable application to share the media with. + * The [LocalMedia.uri] needs to have a file scheme. + */ suspend fun share(localMedia: LocalMedia): Result } From f7f5539cb900fb4cde59d23b86443e1407273fb4 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 19:22:23 +0200 Subject: [PATCH 08/32] Update localazy strings --- .../createroom/impl/configureroom/ConfigureRoomView.kt | 4 ++-- features/createroom/impl/src/main/res/values/localazy.xml | 2 -- features/invitelist/impl/src/main/res/values/localazy.xml | 4 ++-- .../features/roomdetails/impl/edit/RoomDetailsEditView.kt | 4 ++-- features/roomdetails/impl/src/main/res/values/localazy.xml | 4 +--- libraries/ui-strings/src/main/res/values/localazy.xml | 3 +++ 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index 4aae1936a9..05949f98e3 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -226,7 +226,7 @@ fun RoomNameWithAvatar( LabelledTextField( label = stringResource(R.string.screen_create_room_room_name_label), value = roomName, - placeholder = stringResource(R.string.screen_create_room_room_name_placeholder), + placeholder = stringResource(StringR.string.common_room_name_placeholder), singleLine = true, onValueChange = onRoomNameChanged, ) @@ -243,7 +243,7 @@ fun RoomTopic( modifier = modifier, label = stringResource(R.string.screen_create_room_topic_label), value = topic, - placeholder = stringResource(R.string.screen_create_room_topic_placeholder), + placeholder = stringResource(StringR.string.common_topic_placeholder), onValueChange = onTopicChanged, maxLines = 3, ) diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml index b3c0a6e618..0b6d87b8b8 100644 --- a/features/createroom/impl/src/main/res/values/localazy.xml +++ b/features/createroom/impl/src/main/res/values/localazy.xml @@ -9,9 +9,7 @@ "Messages are not encrypted and anyone can read them. You can enable encryption at a later date." "Public room (anyone)" "Room name" - "e.g. Product Sprint" "Topic (optional)" - "What is this room about?" "An error occurred when trying to start a chat" "Create a room" \ No newline at end of file diff --git a/features/invitelist/impl/src/main/res/values/localazy.xml b/features/invitelist/impl/src/main/res/values/localazy.xml index 56163ec3a8..6d52033110 100644 --- a/features/invitelist/impl/src/main/res/values/localazy.xml +++ b/features/invitelist/impl/src/main/res/values/localazy.xml @@ -5,5 +5,5 @@ "Are you sure you want to decline to chat with %1$s?" "Decline chat" "No Invites" - "%1$s invited you" - + "%1$s (%2$s) invited you" + \ No newline at end of file diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt index 414eaf9831..071200d16d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -145,7 +145,7 @@ fun RoomDetailsEditView( LabelledTextField( label = stringResource(id = R.string.screen_room_details_room_name_label), value = state.roomName, - placeholder = stringResource(id = R.string.screen_room_details_room_name_placeholder), + placeholder = stringResource(id = StringR.string.common_room_name_placeholder), singleLine = true, onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) }, ) @@ -162,7 +162,7 @@ fun RoomDetailsEditView( LabelledTextField( label = stringResource(id = StringR.string.common_topic), value = state.roomTopic, - placeholder = stringResource(id = R.string.screen_room_details_topic_placeholder), + placeholder = stringResource(id = StringR.string.common_topic_placeholder), maxLines = 10, onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) }, ) diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 4f45bb6b49..5fffafb51d 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -12,10 +12,9 @@ "Unable to update room" "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." "Message encryption enabled" + "Invite people" "Room name" - "e.g. Product Sprint" "Share room" - "What is this room about?" "Updating room…" "Pending" "Room members" @@ -25,7 +24,6 @@ "Unblock" "On unblocking the user, you will be able to see all messages by them again." "Unblock user" - "Invite friends to Element" "Leave room" "People" "Security" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 954d078d01..06d6d5661b 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -69,6 +69,7 @@ "Encryption enabled" "Error" "File" + "File saved to Downloads" "GIF" "Image" "We can’t validate this user’s Matrix ID. The invite might not be received." @@ -89,6 +90,7 @@ "Report a bug" "Report submitted" "Room name" + "e.g. Product Sprint" "Search for someone" "Search results" "Security" @@ -102,6 +104,7 @@ "Success" "Suggestions" "Topic" + "What is this room about?" "Unable to decrypt" "We were unable to successfully send invites to one or more users." "Unable to send invite(s)" From 31ba1a1a06b31e15e7afd800d22288a084bb75b6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 19:22:47 +0200 Subject: [PATCH 09/32] Media action: show snackbar when file saved on disk --- .../features/messages/impl/MessagesView.kt | 15 ++---------- .../impl/media/viewer/MediaViewerPresenter.kt | 15 +++++++++++- .../impl/media/viewer/MediaViewerState.kt | 2 ++ .../media/viewer/MediaViewerStateProvider.kt | 1 + .../impl/media/viewer/MediaViewerView.kt | 17 +++++++++++++- .../features/roomlist/impl/RoomListView.kt | 22 ++---------------- .../{SnackbarDispatcher.kt => Snackbar.kt} | 23 +++++++++++++++++++ 7 files changed, 60 insertions(+), 35 deletions(-) rename libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/{SnackbarDispatcher.kt => Snackbar.kt} (72%) 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 579dfeaf5a..05ee38a2ec 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 @@ -76,6 +76,7 @@ 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 io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch @@ -94,23 +95,11 @@ fun MessagesView( modifier: Modifier = Modifier, ) { LogCompositions(tag = "MessagesScreen", msg = "Root") - val coroutineScope = rememberCoroutineScope() var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) } AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) - val snackbarHostState = remember { SnackbarHostState() } - val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) } - if (snackbarMessageText != null) { - SideEffect { - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = snackbarMessageText, - duration = state.snackbarMessage.duration - ) - } - } - } + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose val localView = LocalView.current 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 index b8e175424a..65c84f70b1 100644 --- 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 @@ -33,16 +33,21 @@ import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.features.messages.impl.media.local.createFromMediaFile import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.designsystem.utils.handleSnackbarMessage 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 +import io.element.android.libraries.ui.strings.R as StringR class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerNode.Inputs, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val mediaActionsHandler: LocalMediaActions, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @AssistedFactory @@ -60,6 +65,7 @@ class MediaViewerPresenter @AssistedInject constructor( val localMedia: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) DisposableEffect(loadMediaTrigger) { coroutineScope.downloadMedia(mediaFile, localMedia) onDispose { @@ -81,6 +87,7 @@ class MediaViewerPresenter @AssistedInject constructor( mimeType = inputs.mimeType, thumbnailSource = inputs.thumbnailSource, downloadedMedia = localMedia.value, + snackbarMessage = snackbarMessage, eventSink = ::handleEvents ) } @@ -105,7 +112,13 @@ class MediaViewerPresenter @AssistedInject constructor( private fun CoroutineScope.saveOnDisk(localMedia: Async) = launch { when (localMedia) { - is Async.Success -> mediaActionsHandler.saveOnDisk(localMedia.state) + is Async.Success -> { + mediaActionsHandler.saveOnDisk(localMedia.state) + .onSuccess { + val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) + snackbarDispatcher.post(snackbarMessage) + } + } else -> Unit } } 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 index c42263dd3e..77950a4bf3 100644 --- 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 @@ -18,6 +18,7 @@ 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.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.media.MediaSource data class MediaViewerState( @@ -25,5 +26,6 @@ data class MediaViewerState( val mimeType: String?, val thumbnailSource: MediaSource?, val downloadedMedia: Async, + val snackbarMessage: SnackbarMessage?, 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 index 6c54eb3b05..4f12be9ba9 100644 --- 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 @@ -50,4 +50,5 @@ fun aMediaViewerState(downloadedMedia: Async = Async.Uninitialized) mimeType = MimeTypes.IMAGE_JPEG, thumbnailSource = null, downloadedMedia = downloadedMedia, + snackbarMessage = null ) {} 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 index 721cb76350..24efe4dbe9 100644 --- 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 @@ -28,6 +28,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -53,6 +56,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.ui.strings.R.* @@ -96,10 +100,21 @@ fun MediaViewerView( showThumbnail = false } + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + Scaffold(modifier, topBar = { MediaViewerTopBar(onBackPressed, state.eventSink) - } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary + ) + } + }, ) { Box( modifier = Modifier diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 49b2c3604c..112d1afb75 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -40,17 +40,13 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -80,8 +76,8 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.RoomId -import kotlinx.coroutines.launch import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.ui.strings.R as StringR @@ -179,21 +175,7 @@ fun RoomListContent( } } - val snackbarHostState = remember { SnackbarHostState() } - val snackbarMessageText = if (state.snackbarMessage != null) { - stringResource(state.snackbarMessage.messageResId) - } else null - val coroutineScope = rememberCoroutineScope() - if (snackbarMessageText != null) { - SideEffect { - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = snackbarMessageText, - duration = SnackbarDuration.Short, - ) - } - } - } + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt similarity index 72% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index 1131777398..9a31b92182 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcher.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -18,10 +18,14 @@ package io.element.android.libraries.designsystem.utils import androidx.annotation.StringRes import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -64,6 +68,25 @@ fun handleSnackbarMessage( return snackbarMessage } +@Composable +fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val snackbarMessageText = snackbarMessage?.let { + stringResource(id = snackbarMessage.messageResId) + } + LaunchedEffect(snackbarMessage) { + if (snackbarMessageText == null) return@LaunchedEffect + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = snackbarMessageText, + duration = snackbarMessage.duration, + ) + } + } + return snackbarHostState +} + data class SnackbarMessage( @StringRes val messageResId: Int, val duration: SnackbarDuration = SnackbarDuration.Short, From 8e1ade897d97fce1c270a8bf2195205f28f2026d Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 20:13:17 +0200 Subject: [PATCH 10/32] MediaViewer: improve actions (save on disk and share) --- app/src/main/AndroidManifest.xml | 3 +++ .../impl/media/local/AndroidLocalMediaActions.kt | 11 ++--------- .../impl/media/viewer/MediaViewerPresenter.kt | 6 +++++- .../messages/impl/media/viewer/MediaViewerView.kt | 9 ++++++++- .../libraries/matrix/api/media/MatrixMediaLoader.kt | 2 +- .../libraries/matrix/impl/media/RustMediaLoader.kt | 4 ++-- .../libraries/matrix/test/media/FakeMediaLoader.kt | 2 +- 7 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5eacd03a4..d2d648f145 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,9 @@ + + + , localMedia: MutableState>) = launch { localMedia.value = Async.Loading() - mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType) + mediaLoader.downloadMediaFile( + source = inputs.mediaSource, + mimeType = inputs.mimeType, + body = inputs.name + ) .onSuccess { mediaFile.value = it }.mapCatching { mediaFile -> 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 index 24efe4dbe9..38b7628626 100644 --- 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 @@ -104,7 +104,11 @@ fun MediaViewerView( Scaffold(modifier, topBar = { - MediaViewerTopBar(onBackPressed, state.eventSink) + MediaViewerTopBar( + actionsEnabled = state.downloadedMedia is Async.Success, + onBackPressed = onBackPressed, + eventSink = state.eventSink + ) }, snackbarHost = { SnackbarHost(snackbarHostState) { data -> @@ -145,6 +149,7 @@ fun MediaViewerView( @Composable private fun MediaViewerTopBar( + actionsEnabled : Boolean, onBackPressed: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { @@ -153,6 +158,7 @@ private fun MediaViewerTopBar( navigationIcon = { BackButton(onClick = onBackPressed) }, actions = { IconButton( + enabled = actionsEnabled, onClick = { eventSink(MediaViewerEvents.Share) }, @@ -160,6 +166,7 @@ private fun MediaViewerTopBar( Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = string.action_share)) } IconButton( + enabled = actionsEnabled, onClick = { eventSink(MediaViewerEvents.SaveOnDisk) }, 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 index 4d1d2445ce..a2e1c99572 100644 --- 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 @@ -36,5 +36,5 @@ interface MatrixMediaLoader { * @param mimeType: optional mime type * @return a [Result] of [MediaFile] */ - suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result + suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result } 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 index 9e4f2c53de..f71a19658c 100644 --- 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 @@ -59,13 +59,13 @@ class RustMediaLoader( } } - override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result = + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = withContext(dispatchers.io) { runCatching { source.toRustMediaSource().use { mediaSource -> val mediaFile = innerClient.getMediaFile( mediaSource = mediaSource, - body = null, + body = body, mimeType = mimeType ?: "application/octet-stream" ) RustMediaFile(mediaFile) 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 index 96c49aa165..508e6d5da4 100644 --- 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 @@ -44,7 +44,7 @@ class FakeMediaLoader : MatrixMediaLoader { } } - override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result { + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result { delay(FAKE_DELAY_IN_MS) return if (shouldFail) { Result.failure(RuntimeException()) From 3a2ef0238b37de0418b11eba94a21a4cc56be1b2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Jun 2023 20:52:17 +0200 Subject: [PATCH 11/32] Media: handle openWith and share actions (need to inject context for tests...). Also introduce MediaInfo --- .../messages/impl/MessagesFlowNode.kt | 28 ++++--- .../preview/AttachmentsPreviewPresenter.kt | 2 +- .../AttachmentsPreviewStateProvider.kt | 4 +- .../impl/media/helper/fileExtensionAndSize.kt | 28 +++++++ .../media/local/AndroidLocalMediaActions.kt | 44 +++++++--- .../media/local/AndroidLocalMediaFactory.kt | 23 ++++-- .../messages/impl/media/local/LocalMedia.kt | 9 +-- .../impl/media/local/LocalMediaActions.kt | 10 ++- .../impl/media/local/LocalMediaFactory.kt | 11 ++- .../impl/media/local/LocalMediaView.kt | 77 +++++++++++++++++- .../messages/impl/media/local/MediaInfo.kt | 45 +++++++++++ .../impl/media/viewer/MediaViewerEvents.kt | 1 + .../impl/media/viewer/MediaViewerNode.kt | 4 +- .../impl/media/viewer/MediaViewerPresenter.kt | 49 ++++++++--- .../impl/media/viewer/MediaViewerState.kt | 4 +- .../media/viewer/MediaViewerStateProvider.kt | 36 ++++++--- .../impl/media/viewer/MediaViewerView.kt | 81 +++++++++++-------- .../MessageComposerPresenter.kt | 15 ++-- .../TimelineItemContentMessageFactory.kt | 17 ++-- .../model/event/TimelineItemFileContent.kt | 14 +--- .../model/event/TimelineItemImageContent.kt | 3 +- .../event/TimelineItemImageContentProvider.kt | 3 +- .../model/event/TimelineItemVideoContent.kt | 3 +- .../event/TimelineItemVideoContentProvider.kt | 4 +- .../features/messages/fixtures/media.kt | 10 ++- 25 files changed, 395 insertions(+), 130 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt 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 index 3dc77bc00a..b5d3c40cf8 100644 --- 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 @@ -32,6 +32,7 @@ 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.local.MediaInfo 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.TimelineItemFileContent @@ -64,10 +65,9 @@ class MessagesFlowNode @AssistedInject constructor( @Parcelize data class MediaViewer( - val title: String, + val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, - val mimeType: String?, ) : NavTarget @Parcelize @@ -100,10 +100,9 @@ class MessagesFlowNode @AssistedInject constructor( } is NavTarget.MediaViewer -> { val inputs = MediaViewerNode.Inputs( - name = navTarget.title, + mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, - mimeType = navTarget.mimeType, ) createNode(buildContext, listOf(inputs)) } @@ -118,30 +117,39 @@ class MessagesFlowNode @AssistedInject constructor( when (event.content) { is TimelineItemImageContent -> { val navTarget = NavTarget.MediaViewer( - title = event.content.body, + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize + ), 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, + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize + ), mediaSource = mediaSource, thumbnailSource = event.content.thumbnailSource, - mimeType = event.content.mimeType, ) backstack.push(navTarget) } is TimelineItemFileContent -> { val mediaSource = event.content.fileSource val navTarget = NavTarget.MediaViewer( - title = event.content.body, + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize + ), mediaSource = mediaSource, thumbnailSource = event.content.thumbnailSource, - mimeType = event.content.mimeType, ) backstack.push(navTarget) } 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 index d80359e88c..807a23f6cc 100644 --- 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 @@ -84,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( sendActionState: MutableState>, ) { suspend { - mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) + mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible) }.executeResult(sendActionState) } } 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 index 26565a226a..3724bb7f68 100644 --- 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 @@ -20,8 +20,8 @@ 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.features.messages.impl.media.local.anImageInfo import io.element.android.libraries.architecture.Async -import io.element.android.libraries.core.mimetype.MimeTypes open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence @@ -34,7 +34,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider = Async.Uninitialized) = AttachmentsPreviewState( attachment = Attachment.Media( - localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L), + localMedia = LocalMedia("path".toUri(), anImageInfo()), compressIfPossible = true ), sendActionState = sendActionState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt new file mode 100644 index 0000000000..43d6ba2a9b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt @@ -0,0 +1,28 @@ +/* + * 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.helper + +fun formatFileExtensionAndSize(name: String, size: String?): String { + val fileExtension = name.substringAfterLast('.', "").uppercase() + return buildString { + append(fileExtension) + if (size != null) { + append(' ') + append("($size)") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt index 7b10391994..71bb2ae31c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -20,6 +20,7 @@ import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore @@ -60,16 +61,17 @@ class AndroidLocalMediaActions @Inject constructor( } } - override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun share(activityContext: Context, localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { - val authority = "${buildMeta.applicationId}.fileprovider" - val uriFromFileProvider = FileProvider.getUriForFile(context, authority, localMedia.toFile()) - val shareMediaIntent = Intent(Intent.ACTION_VIEW) - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - .setDataAndType(uriFromFileProvider, localMedia.mimeType) + val shareableUri = localMedia.toShareableUri() + val shareMediaIntent = Intent(Intent.ACTION_SEND) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, shareableUri) + .setTypeAndNormalize(localMedia.info.mimeType) withContext(coroutineDispatchers.main) { - context.startActivity(shareMediaIntent, null) + val intent = Intent.createChooser(shareMediaIntent, null) + activityContext.startActivity(intent) } }.onSuccess { Timber.v("Share media succeed") @@ -78,11 +80,33 @@ class AndroidLocalMediaActions @Inject constructor( } } + override suspend fun open(activityContext: Context, localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatching { + val openMediaIntent = Intent(Intent.ACTION_VIEW) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType) + withContext(coroutineDispatchers.main) { + activityContext.startActivity(openMediaIntent) + } + }.onSuccess { + Timber.v("Open media succeed") + }.onFailure { + Timber.e(it, "Open media failed") + } + } + + private fun LocalMedia.toShareableUri(): Uri { + val mediaAsFile = this.toFile() + val authority = "${buildMeta.applicationId}.fileprovider" + return FileProvider.getUriForFile(context, authority, mediaAsFile).normalizeScheme() + } + @RequiresApi(Build.VERSION_CODES.Q) private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) { val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.name) - put(MediaStore.MediaColumns.MIME_TYPE, localMedia.mimeType) + put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.name) + put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } val resolver = context.contentResolver @@ -99,7 +123,7 @@ class AndroidLocalMediaActions @Inject constructor( private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) { val target = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - localMedia.name ?: "" + localMedia.info.name ) localMedia.openStream()?.use { input -> FileOutputStream(target).use { output -> 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 index 4908d79f68..ac22785277 100644 --- 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 @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.media.local import android.content.Context import android.net.Uri import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.libraries.androidutils.file.getFileName import io.element.android.libraries.androidutils.file.getFileSize import io.element.android.libraries.androidutils.file.getMimeType @@ -29,18 +30,26 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class AndroidLocalMediaFactory @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val fileSizeFormatter: FileSizeFormatter, ) : LocalMediaFactory { - override fun createFromUri(uri: Uri, mimeType: String?, name: String?): LocalMedia { + override fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia { val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream - val fileName = name ?: context.getFileName(uri) - val fileSize = context.getFileSize(uri) + val fileName = name ?: context.getFileName(uri) ?: "" + val fileSize = formattedFileSize ?: fileSizeFormatter.format(context.getFileSize(uri)) return LocalMedia( uri = uri, - mimeType = resolvedMimeType, - name = fileName, - size = fileSize + info = MediaInfo( + mimeType = resolvedMimeType, + name = fileName, + formattedFileSize = 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 index e125e531bf..f5a09def47 100644 --- 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 @@ -19,14 +19,13 @@ 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 + val info: MediaInfo, +) : Parcelable { + +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt index 03f09488df..23c029c09f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -16,6 +16,8 @@ package io.element.android.features.messages.impl.media.local +import android.content.Context + interface LocalMediaActions { /** * Will save the current media to the Downloads directory. @@ -27,6 +29,12 @@ interface LocalMediaActions { * Will try to find a suitable application to share the media with. * The [LocalMedia.uri] needs to have a file scheme. */ - suspend fun share(localMedia: LocalMedia): Result + suspend fun share(activityContext: Context, localMedia: LocalMedia): Result + + /** + * Will try to find a suitable application to open the media with. + * The [LocalMedia.uri] needs to have a file scheme. + */ + suspend fun open(activityContext: Context, localMedia: LocalMedia): Result } 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 index 917369292a..c90e9d43ed 100644 --- 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 @@ -24,22 +24,21 @@ import io.element.android.libraries.matrix.api.media.toFile interface LocalMediaFactory { /** - * 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. - * If the [name] is null, it'll try to read it from the content. + * This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize + * If any of those params are null, it'll try to read them from the content. */ fun createFromUri( uri: Uri, mimeType: String?, name: String?, + formattedFileSize: String? ): LocalMedia } fun LocalMediaFactory.createFromMediaFile( mediaFile: MediaFile, - mimeType: String?, - name: String? + mediaInfo: MediaInfo, ): LocalMedia { val uri = mediaFile.toFile().toUri() - return createFromUri(uri = uri, mimeType = mimeType, name = name) + return createFromUri(uri = uri, mimeType = mediaInfo.mimeType, name = mediaInfo.name, formattedFileSize = mediaInfo.formattedFileSize) } 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 index 28b49cf8ec..502e10e0a4 100644 --- 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 @@ -17,18 +17,35 @@ package io.element.android.features.messages.impl.media.local import android.annotation.SuppressLint +import android.net.Uri import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate 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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem @@ -36,6 +53,7 @@ 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.helper.formatFileExtensionAndSize import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper import io.element.android.features.messages.impl.media.local.pdf.PdfViewer import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState @@ -43,6 +61,8 @@ 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.R +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomableState @@ -55,12 +75,13 @@ import me.saket.telephoto.zoomable.rememberZoomableState fun LocalMediaView( localMedia: LocalMedia?, modifier: Modifier = Modifier, - mimeType: String? = localMedia?.mimeType, + info: MediaInfo? = localMedia?.info, onReady: () -> Unit = {}, ) { val zoomableState = rememberZoomableState( zoomSpec = ZoomSpec(maxZoomFactor = 5f) ) + val mimeType = info?.mimeType when { mimeType.isMimeTypeImage() -> MediaImageView( localMedia = localMedia, @@ -79,7 +100,12 @@ fun LocalMediaView( onReady = onReady, modifier = modifier ) - else -> Unit + else -> MediaFileView( + uri = localMedia?.uri, + info = info, + onReady = onReady, + modifier = modifier + ) } } @@ -186,3 +212,50 @@ fun MediaPDFView( } PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) } + +@Composable +fun MediaFileView( + uri: Uri?, + info: MediaInfo?, + onReady: () -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { + if(uri != null) { + onReady() + } + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Attachment, + contentDescription = "OpenFile", + tint = MaterialTheme.colorScheme.background, + modifier = Modifier + .size(32.dp) + .rotate(-45f), + ) + } + if(info == null) return + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = info.name, + maxLines = 2, + fontSize = 16.sp, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.name, info.formattedFileSize), + fontSize = 14.sp, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt new file mode 100644 index 0000000000..005da816cc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt @@ -0,0 +1,45 @@ +/* + * 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.os.Parcelable +import io.element.android.libraries.core.mimetype.MimeTypes +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MediaInfo( + val name: String, + val mimeType: String, + val formattedFileSize: String, +) : Parcelable + + +fun anImageInfo(): MediaInfo = MediaInfo( + "an image file", MimeTypes.Jpeg, "4MB" +) + +fun aVideoInfo(): MediaInfo = MediaInfo( + "a video file", MimeTypes.Mp4, "14MB" +) + +fun aPdfInfo(): MediaInfo = MediaInfo( + "a pdf file", MimeTypes.Pdf, "23MB" +) + +fun aFileInfo(): MediaInfo = MediaInfo( + "an apk file", MimeTypes.Apk, "50MB" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt index 375b7e4a34..b680ee58c9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.media.viewer sealed interface MediaViewerEvents { object SaveOnDisk: MediaViewerEvents object Share: MediaViewerEvents + object OpenWith: 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 index ee1bb50ce9..32bf2e39ac 100644 --- 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 @@ -24,6 +24,7 @@ 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.media.local.MediaInfo import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme @@ -38,10 +39,9 @@ class MediaViewerNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { data class Inputs( - val name: String, + val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, - val mimeType: String? ) : NodeInputs private val inputs: Inputs = inputs() 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 index faa86dac71..7aa2c0bd9b 100644 --- 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 @@ -16,6 +16,8 @@ package io.element.android.features.messages.impl.media.viewer +import android.content.ActivityNotFoundException +import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -24,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -40,6 +43,7 @@ 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 +import io.element.android.libraries.androidutils.R as UtilsR import io.element.android.libraries.ui.strings.R as StringR class MediaViewerPresenter @AssistedInject constructor( @@ -65,6 +69,7 @@ class MediaViewerPresenter @AssistedInject constructor( val localMedia: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val context = LocalContext.current val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) DisposableEffect(loadMediaTrigger) { coroutineScope.downloadMedia(mediaFile, localMedia) @@ -78,13 +83,13 @@ class MediaViewerPresenter @AssistedInject constructor( MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) - MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) + MediaViewerEvents.Share -> coroutineScope.share(context, localMedia.value) + MediaViewerEvents.OpenWith -> coroutineScope.open(context, localMedia.value) } } return MediaViewerState( - name = inputs.name, - mimeType = inputs.mimeType, + mediaInfo = inputs.mediaInfo, thumbnailSource = inputs.thumbnailSource, downloadedMedia = localMedia.value, snackbarMessage = snackbarMessage, @@ -96,16 +101,15 @@ class MediaViewerPresenter @AssistedInject constructor( localMedia.value = Async.Loading() mediaLoader.downloadMediaFile( source = inputs.mediaSource, - mimeType = inputs.mimeType, - body = inputs.name + mimeType = inputs.mediaInfo.mimeType, + body = inputs.mediaInfo.name ) .onSuccess { mediaFile.value = it }.mapCatching { mediaFile -> localMediaFactory.createFromMediaFile( mediaFile = mediaFile, - mimeType = inputs.mimeType, - name = inputs.name + mediaInfo = inputs.mediaInfo ) }.onSuccess { localMedia.value = Async.Success(it) @@ -127,12 +131,39 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun CoroutineScope.share(localMedia: Async) = launch { + private fun CoroutineScope.share(activityContext: Context, localMedia: Async) = launch { when (localMedia) { - is Async.Success -> mediaActionsHandler.share(localMedia.state) + is Async.Success -> { + mediaActionsHandler.share(activityContext, localMedia.state) + .onFailure { + val snackbarMessage = SnackbarMessage(openShareError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } else -> Unit } } + + private fun CoroutineScope.open(activityContext: Context, localMedia: Async) = launch { + when (localMedia) { + is Async.Success -> { + mediaActionsHandler.open(activityContext, localMedia.state) + .onFailure { + val snackbarMessage = SnackbarMessage(openShareError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } + else -> Unit + } + } + + private fun openShareError(throwable: Throwable): Int { + return if (throwable is ActivityNotFoundException) { + UtilsR.string.error_no_compatible_app_found + } else { + StringR.string.error_unknown + } + } } 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 index 77950a4bf3..18375746c5 100644 --- 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 @@ -17,13 +17,13 @@ package io.element.android.features.messages.impl.media.viewer import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.media.MediaSource data class MediaViewerState( - val name: String, - val mimeType: String?, + val mediaInfo: MediaInfo, val thumbnailSource: MediaSource?, val downloadedMedia: Async, val snackbarMessage: SnackbarMessage?, 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 index 4f12be9ba9..2705072f0d 100644 --- 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 @@ -18,8 +18,12 @@ 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.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.aFileInfo +import io.element.android.features.messages.impl.media.local.aPdfInfo +import io.element.android.features.messages.impl.media.local.aVideoInfo +import io.element.android.features.messages.impl.media.local.anImageInfo import io.element.android.libraries.architecture.Async open class MediaViewerStateProvider : PreviewParameterProvider { @@ -30,24 +34,36 @@ open class MediaViewerStateProvider : PreviewParameterProvider aMediaViewerState(Async.Failure(IllegalStateException())), aMediaViewerState( Async.Success( - LocalMedia( - Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L - ) + LocalMedia(Uri.EMPTY, anImageInfo()) ), + anImageInfo(), ), aMediaViewerState( Async.Success( - LocalMedia( - Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L - ) + LocalMedia(Uri.EMPTY, aVideoInfo()) ), + aVideoInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, aPdfInfo()) + ), + aPdfInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, aFileInfo()) + ), + aFileInfo(), ) ) } -fun aMediaViewerState(downloadedMedia: Async = Async.Uninitialized) = MediaViewerState( - name = "A media", - mimeType = MimeTypes.IMAGE_JPEG, +fun aMediaViewerState( + downloadedMedia: Async = Async.Uninitialized, + mediaInfo: MediaInfo = anImageInfo(), +) = MediaViewerState( + mediaInfo = mediaInfo, thumbnailSource = null, downloadedMedia = downloadedMedia, snackbarMessage = null 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 index 38b7628626..3373b56726 100644 --- 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 @@ -22,12 +22,18 @@ 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.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost @@ -43,15 +49,14 @@ 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 androidx.compose.ui.unit.dp 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.button.BackButton 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.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Scaffold @@ -102,7 +107,8 @@ fun MediaViewerView( val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) - Scaffold(modifier, + Scaffold( + modifier, topBar = { MediaViewerTopBar( actionsEnabled = state.downloadedMedia is Async.Success, @@ -120,36 +126,48 @@ fun MediaViewerView( } }, ) { - Box( + Column( 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 + if (showProgress) { + LinearProgressIndicator( + Modifier + .fillMaxWidth() + .height(2.dp) + ) + } else { + Spacer(Modifier.height(2.dp)) + } + Box( + modifier = Modifier.fillMaxSize(), + 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(), + info = state.mediaInfo, + onReady = ::onMediaReady + ) + ThumbnailView( + thumbnailSource = state.thumbnailSource, + showThumbnail = showThumbnail, ) } - LocalMediaView( - localMedia = state.downloadedMedia.dataOrNull(), - mimeType = state.mimeType, - onReady = ::onMediaReady - ) - ThumbnailView( - thumbnailSource = state.thumbnailSource, - showThumbnail = showThumbnail, - showProgress = showProgress, - ) } } } @Composable private fun MediaViewerTopBar( - actionsEnabled : Boolean, + actionsEnabled: Boolean, onBackPressed: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { @@ -160,10 +178,10 @@ private fun MediaViewerTopBar( IconButton( enabled = actionsEnabled, onClick = { - eventSink(MediaViewerEvents.Share) + eventSink(MediaViewerEvents.OpenWith) }, ) { - Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = string.action_share)) + Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = string.action_share)) } IconButton( enabled = actionsEnabled, @@ -173,6 +191,14 @@ private fun MediaViewerTopBar( ) { Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = string.action_save)) } + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.Share) + }, + ) { + Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = string.action_share)) + } } ) } @@ -181,7 +207,6 @@ private fun MediaViewerTopBar( private fun ThumbnailView( thumbnailSource: MediaSource?, showThumbnail: Boolean, - showProgress: Boolean, ) { AnimatedVisibility( visible = showThumbnail, @@ -203,14 +228,6 @@ private fun ThumbnailView( contentScale = ContentScale.Fit, contentDescription = null, ) - if (showProgress) { - Box( - modifier = Modifier.roundedBackground(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index cb966189a0..7c5e2d5705 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -192,7 +192,7 @@ class MessageComposerPresenter @Inject constructor( is Attachment.Media -> { sendMedia( uri = attachment.localMedia.uri, - mimeType = attachment.localMedia.mimeType, + mimeType = attachment.localMedia.info.mimeType, attachmentState = attachmentState ) } @@ -210,12 +210,17 @@ class MessageComposerPresenter @Inject constructor( attachmentsState.value = AttachmentsState.None return } - val localMedia = localMediaFactory.createFromUri(uri, mimeType, null) + val localMedia = localMediaFactory.createFromUri( + uri = uri, + mimeType = mimeType, + name = null, + formattedFileSize = null + ) val mediaAttachment = Attachment.Media(localMedia, compressIfPossible) val isPreviewable = when { - MimeTypes.isImage(localMedia.mimeType) -> true - MimeTypes.isVideo(localMedia.mimeType) -> true - MimeTypes.isAudio(localMedia.mimeType) -> true + MimeTypes.isImage(localMedia.info.mimeType) -> true + MimeTypes.isVideo(localMedia.info.mimeType) -> true + MimeTypes.isAudio(localMedia.info.mimeType) -> true else -> false } attachmentsState.value = if (isPreviewable) { 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 cc8082be62..8941878334 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 @@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.features.messages.impl.timeline.util.toHtmlDocument +import io.element.android.libraries.core.mimetype.MimeTypes 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 @@ -51,11 +52,12 @@ class TimelineItemContentMessageFactory @Inject constructor( TimelineItemImageContent( body = messageType.body, mediaSource = messageType.source, - mimeType = messageType.info?.mimetype, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, blurhash = messageType.info?.blurhash, width = messageType.info?.width?.toInt(), height = messageType.info?.height?.toInt(), - aspectRatio = aspectRatio + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0) ) } is VideoMessageType -> { @@ -64,22 +66,21 @@ class TimelineItemContentMessageFactory @Inject constructor( body = messageType.body, thumbnailSource = messageType.info?.thumbnailSource, videoSource = messageType.source, - mimeType = messageType.info?.mimetype, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, width = messageType.info?.width?.toInt(), height = messageType.info?.height?.toInt(), duration = messageType.info?.duration ?: 0L, blurHash = messageType.info?.blurhash, - aspectRatio = aspectRatio + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0) ) } is FileMessageType -> TimelineItemFileContent( body = messageType.body, thumbnailSource = messageType.info?.thumbnailSource, fileSource = messageType.source, - mimeType = messageType.info?.mimetype, - formattedFileSize = messageType.info?.size?.let { - fileSizeFormatter.format(it) - }, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0) ) is NoticeMessageType -> TimelineItemNoticeContent( body = messageType.body, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt index 9307cf8a67..197bae2dda 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt @@ -16,23 +16,17 @@ package io.element.android.features.messages.impl.timeline.model.event +import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize import io.element.android.libraries.matrix.api.media.MediaSource data class TimelineItemFileContent( val body: String, val fileSource: MediaSource, val thumbnailSource: MediaSource?, - val formattedFileSize: String?, - val mimeType: String?, + val formattedFileSize: String, + val mimeType: String, ) : TimelineItemEventContent { override val type: String = "TimelineItemFileContent" - private val fileExtension = body.substringAfterLast('.', "").uppercase() - val fileExtensionAndSize = buildString { - append(fileExtension) - if (formattedFileSize != null) { - append(' ') - append("($formattedFileSize)") - } - } + val fileExtensionAndSize = formatFileExtensionAndSize(body, formattedFileSize) } 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 850dc9782c..18be8d404d 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 @@ -21,7 +21,8 @@ import io.element.android.libraries.matrix.api.media.MediaSource data class TimelineItemImageContent( val body: String, val mediaSource: MediaSource, - val mimeType: String?, + val formattedFileSize: String, + val mimeType: String, val blurhash: String?, val width: Int?, val height: Int?, 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 97bbf6ed41..66b35a07d3 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 @@ -36,5 +36,6 @@ fun aTimelineItemImageContent() = TimelineItemImageContent( blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", width = null, height = 300, - aspectRatio = 0.5f + aspectRatio = 0.5f, + formattedFileSize = "4MB" ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt index 41fba2a29f..1432ebbda0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt @@ -27,7 +27,8 @@ data class TimelineItemVideoContent( val blurHash: String?, val height: Int?, val width: Int?, - val mimeType: String?, + val mimeType: String, + val formattedFileSize: String, ) : TimelineItemEventContent { override val type: String = "TimelineItemImageContent" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt index 9751bb4067..937f35b349 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaSource open class TimelineItemVideoContentProvider : PreviewParameterProvider { @@ -37,5 +38,6 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent( videoSource = MediaSource(""), height = 300, width = 150, - mimeType = null + mimeType = MimeTypes.Mp4, + formattedFileSize = "14MB" ) 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 index 60a01a76d4..7d5bb79b4a 100644 --- 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 @@ -20,7 +20,7 @@ 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 +import io.element.android.features.messages.impl.media.local.MediaInfo fun aLocalMedia( uri: Uri, @@ -29,9 +29,11 @@ fun aLocalMedia( size: Long = 1000, ) = LocalMedia( uri = uri, - mimeType = mimeType, - name = name, - size = size, + info = MediaInfo( + mimeType = mimeType, + name = name, + formattedFileSize = "${size}B", + ) ) fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( From 61fc57d3efa24267bd11f40f9f4b2fed38bcc389 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Jun 2023 22:45:49 +0200 Subject: [PATCH 12/32] File: improve a bit pdf loading --- .../impl/media/local/pdf/PdfRendererManager.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt index 21eeaa652b..8f6c507eb5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt @@ -43,11 +43,11 @@ class PdfRendererManager( mutex.withLock { withContext(Dispatchers.IO) { pdfRenderer = PdfRenderer(parcelFileDescriptor).apply { - (0 until pageCount).map { pageIndex -> - PdfPage(width, pageIndex, mutex, this, coroutineScope) - }.also { - mutablePdfPages.value = it - } + // Preload just 3 pages so we can render faster + val firstPages = loadPages(from = 0, to = 3) + mutablePdfPages.value = firstPages + val nextPages = loadPages(from = 3, to = pageCount) + mutablePdfPages.value = firstPages + nextPages } } } @@ -65,4 +65,10 @@ class PdfRendererManager( } } } + + private fun PdfRenderer.loadPages(from: Int, to: Int): List { + return (from until minOf(to, pageCount)).map { pageIndex -> + PdfPage(width, pageIndex, mutex, this, coroutineScope) + } + } } From 4a71aab95e22d1bbe81206b087cbefea01ede540 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Jun 2023 23:39:48 +0200 Subject: [PATCH 13/32] Media: rework how we get the activity context (and fix test compilation) --- .../media/local/AndroidLocalMediaActions.kt | 24 +++++++++-- .../impl/media/local/LocalMediaActions.kt | 10 +++-- .../impl/media/viewer/MediaViewerPresenter.kt | 20 ++++----- .../messages/media/FakeLocalMediaActions.kt | 41 +++++++++++++++++++ .../messages/media/FakeLocalMediaFactory.kt | 2 +- .../media/viewer/MediaViewerPresenterTest.kt | 17 +++++--- 6 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt index 71bb2ae31c..44bff4f5ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -25,6 +25,9 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext import androidx.core.content.FileProvider import androidx.core.net.toFile import com.squareup.anvil.annotations.ContributesBinding @@ -46,6 +49,19 @@ class AndroidLocalMediaActions @Inject constructor( private val buildMeta: BuildMeta, ) : LocalMediaActions { + private var activityContext: Context? = null + + @Composable + override fun Configure() { + val context = LocalContext.current + return DisposableEffect(Unit) { + activityContext = context + onDispose { + activityContext = null + } + } + } + override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { @@ -61,7 +77,7 @@ class AndroidLocalMediaActions @Inject constructor( } } - override suspend fun share(activityContext: Context, localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { val shareableUri = localMedia.toShareableUri() @@ -71,7 +87,7 @@ class AndroidLocalMediaActions @Inject constructor( .setTypeAndNormalize(localMedia.info.mimeType) withContext(coroutineDispatchers.main) { val intent = Intent.createChooser(shareMediaIntent, null) - activityContext.startActivity(intent) + activityContext!!.startActivity(intent) } }.onSuccess { Timber.v("Share media succeed") @@ -80,14 +96,14 @@ class AndroidLocalMediaActions @Inject constructor( } } - override suspend fun open(activityContext: Context, localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { val openMediaIntent = Intent(Intent.ACTION_VIEW) .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType) withContext(coroutineDispatchers.main) { - activityContext.startActivity(openMediaIntent) + activityContext!!.startActivity(openMediaIntent) } }.onSuccess { Timber.v("Open media succeed") diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt index 23c029c09f..f35af36057 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -16,9 +16,13 @@ package io.element.android.features.messages.impl.media.local -import android.content.Context +import androidx.compose.runtime.Composable interface LocalMediaActions { + + @Composable + fun Configure() + /** * Will save the current media to the Downloads directory. * The [LocalMedia.uri] needs to have a file scheme. @@ -29,12 +33,12 @@ interface LocalMediaActions { * Will try to find a suitable application to share the media with. * The [LocalMedia.uri] needs to have a file scheme. */ - suspend fun share(activityContext: Context, localMedia: LocalMedia): Result + suspend fun share(localMedia: LocalMedia): Result /** * Will try to find a suitable application to open the media with. * The [LocalMedia.uri] needs to have a file scheme. */ - suspend fun open(activityContext: Context, localMedia: LocalMedia): Result + suspend fun open(localMedia: LocalMedia): Result } 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 index 7aa2c0bd9b..f762caf329 100644 --- 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 @@ -17,7 +17,6 @@ package io.element.android.features.messages.impl.media.viewer import android.content.ActivityNotFoundException -import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -26,7 +25,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -50,7 +48,7 @@ class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerNode.Inputs, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, - private val mediaActionsHandler: LocalMediaActions, + private val localMediaActions: LocalMediaActions, private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @@ -69,8 +67,8 @@ class MediaViewerPresenter @AssistedInject constructor( val localMedia: MutableState> = remember { mutableStateOf(Async.Uninitialized) } - val context = LocalContext.current val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + localMediaActions.Configure() DisposableEffect(loadMediaTrigger) { coroutineScope.downloadMedia(mediaFile, localMedia) onDispose { @@ -83,8 +81,8 @@ class MediaViewerPresenter @AssistedInject constructor( MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) - MediaViewerEvents.Share -> coroutineScope.share(context, localMedia.value) - MediaViewerEvents.OpenWith -> coroutineScope.open(context, localMedia.value) + MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) + MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value) } } @@ -121,7 +119,7 @@ class MediaViewerPresenter @AssistedInject constructor( private fun CoroutineScope.saveOnDisk(localMedia: Async) = launch { when (localMedia) { is Async.Success -> { - mediaActionsHandler.saveOnDisk(localMedia.state) + localMediaActions.saveOnDisk(localMedia.state) .onSuccess { val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) snackbarDispatcher.post(snackbarMessage) @@ -131,10 +129,10 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun CoroutineScope.share(activityContext: Context, localMedia: Async) = launch { + private fun CoroutineScope.share(localMedia: Async) = launch { when (localMedia) { is Async.Success -> { - mediaActionsHandler.share(activityContext, localMedia.state) + localMediaActions.share(localMedia.state) .onFailure { val snackbarMessage = SnackbarMessage(openShareError(it)) snackbarDispatcher.post(snackbarMessage) @@ -144,10 +142,10 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun CoroutineScope.open(activityContext: Context, localMedia: Async) = launch { + private fun CoroutineScope.open(localMedia: Async) = launch { when (localMedia) { is Async.Success -> { - mediaActionsHandler.open(activityContext, localMedia.state) + localMediaActions.open(localMedia.state) .onFailure { val snackbarMessage = SnackbarMessage(openShareError(it)) snackbarDispatcher.post(snackbarMessage) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt new file mode 100644 index 0000000000..6d4c0a0ce0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.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.media + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.LocalMediaActions + +class FakeLocalMediaActions: LocalMediaActions { + + @Composable + override fun Configure() { + //NOOP + } + + override suspend fun saveOnDisk(localMedia: LocalMedia): Result { + return Result.success(Unit) + } + + override suspend fun share(localMedia: LocalMedia): Result { + return Result.success(Unit) + } + + override suspend fun open(localMedia: LocalMedia): Result { + return Result.success(Unit) + } +} 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 index 1f7dda5f34..b13b2bc509 100644 --- 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 @@ -27,7 +27,7 @@ class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory var fallbackMimeType: String = MimeTypes.OctetStream - override fun createFromUri(uri: Uri, mimeType: String?, name: String?): LocalMedia { + override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: 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 index 1ef8097d4a..145ca3d486 100644 --- 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 @@ -24,11 +24,14 @@ 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.local.MediaInfo 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.FakeLocalMediaActions import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher 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 @@ -54,7 +57,7 @@ class MediaViewerPresenterTest { }.test { val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) + assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME) val loadingState = awaitItem() assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1) @@ -74,7 +77,7 @@ class MediaViewerPresenterTest { mediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) + assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME) val loadingState = awaitItem() assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) @@ -97,13 +100,17 @@ class MediaViewerPresenterTest { private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerNode.Inputs( - name = TESTED_MEDIA_NAME, + mediaInfo = MediaInfo(name = TESTED_MEDIA_NAME, + mimeType = mimeType, + formattedFileSize = "14MB" + ), mediaSource = aMediaSource(), - mimeType = mimeType, thumbnailSource = null ), localMediaFactory = localMediaFactory, - mediaLoader = mediaLoader + mediaLoader = mediaLoader, + localMediaActions = FakeLocalMediaActions(), + snackbarDispatcher = SnackbarDispatcher() ) } } From 68e6fc3afa7f7d34c4a4fed43c5688295e5fe4e5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 13:44:49 +0200 Subject: [PATCH 14/32] Media: prepare downloadMediaFile to use tempDir --- .../libraries/matrix/impl/RustMatrixClient.kt | 3 ++- .../impl/auth/RustMatrixAuthenticationService.kt | 4 ++++ .../libraries/matrix/impl/media/RustMediaLoader.kt | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) 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 f29482edfc..51fc903d8d 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 @@ -79,6 +79,7 @@ class RustMatrixClient constructor( private val coroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, private val baseDirectory: File, + private val baseCacheDirectory: File, private val clock: SystemClock, ) : MatrixClient { @@ -188,7 +189,7 @@ class RustMatrixClient constructor( override val invitesDataSource: RoomSummaryDataSource get() = rustInvitesDataSource - private val rustMediaLoader = RustMediaLoader(dispatchers, client) + private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client) override val mediaLoader: MatrixMediaLoader get() = rustMediaLoader diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index df3c7f3d6f..d599029923 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -16,10 +16,12 @@ package io.element.android.libraries.matrix.impl.auth +import android.content.Context import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -48,6 +50,7 @@ import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthentication @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) class RustMatrixAuthenticationService @Inject constructor( + @ApplicationContext private val context: Context, private val baseDirectory: File, private val coroutineScope: CoroutineScope, private val coroutineDispatchers: CoroutineDispatchers, @@ -179,6 +182,7 @@ class RustMatrixAuthenticationService @Inject constructor( coroutineScope = coroutineScope, dispatchers = coroutineDispatchers, baseDirectory = baseDirectory, + baseCacheDirectory = context.cacheDir, clock = clock, ) } 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 index f71a19658c..df620852cc 100644 --- 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 @@ -24,13 +24,21 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.mediaSourceFromUrl import org.matrix.rustcomponents.sdk.use +import java.io.File import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource class RustMediaLoader( + baseCacheDirectory: File, private val dispatchers: CoroutineDispatchers, - private val innerClient: Client + private val innerClient: Client, ) : MatrixMediaLoader { + private val cacheDirectory = File(baseCacheDirectory, "temp/media").apply { + if (!exists()) { + mkdirs() + } + } + @OptIn(ExperimentalUnsignedTypes::class) override suspend fun loadMediaContent(source: MediaSource): Result = withContext(dispatchers.io) { @@ -66,7 +74,9 @@ class RustMediaLoader( val mediaFile = innerClient.getMediaFile( mediaSource = mediaSource, body = body, - mimeType = mimeType ?: "application/octet-stream" + mimeType = mimeType ?: "application/octet-stream", + //TODO uncomment when rust api will be merged + //tempDir = cacheDirectory.path, ) RustMediaFile(mediaFile) } From 80b918b273dbe06115f5814137d32093ac44cb06 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 13:48:12 +0200 Subject: [PATCH 15/32] Media: make tests passing again --- .../media/local/AndroidLocalMediaFactory.kt | 13 +++++++++++++ .../impl/media/local/LocalMediaFactory.kt | 18 ++++++++---------- .../messages/impl/media/local/MediaInfo.kt | 1 - .../impl/media/viewer/MediaViewerPresenter.kt | 1 - .../AttachmentsPreviewPresenterTest.kt | 1 - .../features/messages/fixtures/media.kt | 13 ++++--------- .../messages/media/FakeLocalMediaFactory.kt | 14 +++++++++++++- .../media/viewer/MediaViewerPresenterTest.kt | 4 ++-- 8 files changed, 40 insertions(+), 25 deletions(-) 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 index ac22785277..22d03831b5 100644 --- 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 @@ -18,6 +18,7 @@ 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.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.libraries.androidutils.file.getFileName @@ -26,6 +27,8 @@ import io.element.android.libraries.androidutils.file.getMimeType 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 io.element.android.libraries.matrix.api.media.toFile import javax.inject.Inject @ContributesBinding(AppScope::class) @@ -34,6 +37,16 @@ class AndroidLocalMediaFactory @Inject constructor( private val fileSizeFormatter: FileSizeFormatter, ) : LocalMediaFactory { + override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia { + val uri = mediaFile.toFile().toUri() + return createFromUri( + uri = uri, + mimeType = mediaInfo.mimeType, + name = mediaInfo.name, + formattedFileSize = mediaInfo.formattedFileSize + ) + } + override fun createFromUri( uri: Uri, mimeType: String?, 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 index c90e9d43ed..25ade89d91 100644 --- 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 @@ -17,12 +17,18 @@ package io.element.android.features.messages.impl.media.local import android.net.Uri -import androidx.core.net.toUri import io.element.android.libraries.matrix.api.media.MediaFile -import io.element.android.libraries.matrix.api.media.toFile interface LocalMediaFactory { + /** + * This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo] + */ + fun createFromMediaFile( + mediaFile: MediaFile, + mediaInfo: MediaInfo, + ): LocalMedia + /** * This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize * If any of those params are null, it'll try to read them from the content. @@ -34,11 +40,3 @@ interface LocalMediaFactory { formattedFileSize: String? ): LocalMedia } - -fun LocalMediaFactory.createFromMediaFile( - mediaFile: MediaFile, - mediaInfo: MediaInfo, -): LocalMedia { - val uri = mediaFile.toFile().toUri() - return createFromUri(uri = uri, mimeType = mediaInfo.mimeType, name = mediaInfo.name, formattedFileSize = mediaInfo.formattedFileSize) -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt index 005da816cc..889a169365 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt @@ -27,7 +27,6 @@ data class MediaInfo( val formattedFileSize: String, ) : Parcelable - fun anImageInfo(): MediaInfo = MediaInfo( "an image file", MimeTypes.Jpeg, "4MB" ) 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 index f762caf329..1efb86d25b 100644 --- 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 @@ -31,7 +31,6 @@ import dagger.assisted.AssistedInject import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaActions import io.element.android.features.messages.impl.media.local.LocalMediaFactory -import io.element.android.features.messages.impl.media.local.createFromMediaFile import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.SnackbarDispatcher 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 index 7d7316b290..3789c36146 100644 --- 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 @@ -92,7 +92,6 @@ class AttachmentsPreviewPresenterTest { private fun anAttachmentsPreviewPresenter( localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, - mimeType = MimeTypes.IMAGE_JPEG ), room: MatrixRoom = FakeMatrixRoom() ): AttachmentsPreviewPresenter { 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 index 7d5bb79b4a..1357c05913 100644 --- 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 @@ -17,23 +17,18 @@ 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.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.anImageInfo +import io.element.android.libraries.core.mimetype.MimeTypes fun aLocalMedia( uri: Uri, - mimeType: String = MimeTypes.IMAGE_JPEG, - name: String = "a media", - size: Long = 1000, + mediaInfo: MediaInfo = anImageInfo(), ) = LocalMedia( uri = uri, - info = MediaInfo( - mimeType = mimeType, - name = name, - formattedFileSize = "${size}B", - ) + info = mediaInfo ) fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( 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 index b13b2bc509..976aa049f3 100644 --- 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 @@ -20,14 +20,26 @@ 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.features.messages.impl.media.local.MediaInfo import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaFile class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory { var fallbackMimeType: String = MimeTypes.OctetStream + var fallbackName: String = "File name" + var fallbackFileSize = "0B" + + override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia { + return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo) + } override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia { - return aLocalMedia(uri, mimeType ?: fallbackMimeType) + val mediaInfo = MediaInfo( + name = name ?: fallbackName, + mimeType = mimeType ?: fallbackMimeType, + formattedFileSize = formattedFileSize ?: fallbackFileSize + ) + return aLocalMedia(uri, mediaInfo) } } 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 index 145ca3d486..79c41e1cde 100644 --- 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 @@ -45,8 +45,8 @@ private const val TESTED_MEDIA_NAME = "MediaName" class MediaViewerPresenterTest { - private val mockMediaUrl: Uri = mockk("localMediaUri") - private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) + private val mockMediaUri: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) private val mediaLoader = FakeMediaLoader() @Test From 730f50a43381466df333fabb717634fbd48602a3 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 22:08:57 +0200 Subject: [PATCH 16/32] Media actions: add more tests --- .../impl/media/viewer/MediaViewerPresenter.kt | 10 +- .../messages/media/FakeLocalMediaActions.kt | 30 +++-- .../media/viewer/MediaViewerPresenterTest.kt | 103 +++++++++++++----- .../libraries/designsystem/utils/Snackbar.kt | 3 +- libraries/matrix/test/build.gradle.kts | 2 + .../libraries/matrix/test/FakeMatrixClient.kt | 6 +- .../matrix/test/media/FakeMediaLoader.kt | 26 ++--- .../android/samples/minimal/MainActivity.kt | 1 + tests/testutils/build.gradle.kts | 6 - 9 files changed, 130 insertions(+), 57 deletions(-) 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 index 1efb86d25b..816e3c5acb 100644 --- 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 @@ -123,6 +123,10 @@ class MediaViewerPresenter @AssistedInject constructor( val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) snackbarDispatcher.post(snackbarMessage) } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } } else -> Unit } @@ -133,7 +137,7 @@ class MediaViewerPresenter @AssistedInject constructor( is Async.Success -> { localMediaActions.share(localMedia.state) .onFailure { - val snackbarMessage = SnackbarMessage(openShareError(it)) + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) snackbarDispatcher.post(snackbarMessage) } } @@ -146,7 +150,7 @@ class MediaViewerPresenter @AssistedInject constructor( is Async.Success -> { localMediaActions.open(localMedia.state) .onFailure { - val snackbarMessage = SnackbarMessage(openShareError(it)) + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) snackbarDispatcher.post(snackbarMessage) } } @@ -154,7 +158,7 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun openShareError(throwable: Throwable): Int { + private fun mediaActionsError(throwable: Throwable): Int { return if (throwable is ActivityNotFoundException) { UtilsR.string.error_no_compatible_app_found } else { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt index 6d4c0a0ce0..25a62e439a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt @@ -19,23 +19,39 @@ package io.element.android.features.messages.media import androidx.compose.runtime.Composable import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaActions +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import kotlinx.coroutines.withContext -class FakeLocalMediaActions: LocalMediaActions { +class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions { + + var shouldFail = false @Composable override fun Configure() { //NOOP } - override suspend fun saveOnDisk(localMedia: LocalMedia): Result { - return Result.success(Unit) + override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } } - override suspend fun share(localMedia: LocalMedia): Result { - return Result.success(Unit) + override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } } - override suspend fun open(localMedia: LocalMedia): Result { - return Result.success(Unit) + override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } } } 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 index 79c41e1cde..86c3f3a7a4 100644 --- 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 @@ -19,7 +19,6 @@ package io.element.android.features.messages.media.viewer import android.net.Uri -import androidx.media3.common.MimeTypes import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test @@ -32,55 +31,110 @@ import io.element.android.features.messages.media.FakeLocalMediaActions import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.utils.SnackbarDispatcher -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 io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi 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" +private val TESTED_MEDIA_INFO = MediaInfo( + name = "", + mimeType = "", + formattedFileSize = "" +) class MediaViewerPresenterTest { private val mockMediaUri: Uri = mockk("localMediaUri") private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) - private val mediaLoader = FakeMediaLoader() @Test fun `present - download media success scenario`() = runTest { - val presenter = aMediaViewerPresenter() + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.mediaInfo.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) + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java) + state = awaitItem() + val successData = state.downloadedMedia.dataOrNull() + assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java) assertThat(successData).isNotNull() } } + @Test + fun `present - check all actions `() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java) + // no state changes while media is loading + state.eventSink(MediaViewerEvents.OpenWith) + state.eventSink(MediaViewerEvents.Share) + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.OpenWith) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.Share) + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + + // Check failures + mediaActions.shouldFail = true + state.eventSink(MediaViewerEvents.OpenWith) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.Share) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + } + } + @Test fun `present - download media failure then retry with success scenario`() = runTest { - val presenter = aMediaViewerPresenter() + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { mediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME) + assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) 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 @@ -89,7 +143,6 @@ class MediaViewerPresenterTest { 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) @@ -97,19 +150,19 @@ class MediaViewerPresenterTest { } } - private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { + private fun aMediaViewerPresenter( + mediaLoader: FakeMediaLoader, + localMediaActions: FakeLocalMediaActions, + ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerNode.Inputs( - mediaInfo = MediaInfo(name = TESTED_MEDIA_NAME, - mimeType = mimeType, - formattedFileSize = "14MB" - ), + mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null ), localMediaFactory = localMediaFactory, mediaLoader = mediaLoader, - localMediaActions = FakeLocalMediaActions(), + localMediaActions = localMediaActions, snackbarDispatcher = SnackbarDispatcher() ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index 9a31b92182..35b81ff324 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -60,7 +59,7 @@ fun handleSnackbarMessage( val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) LaunchedEffect(snackbarMessage) { if (snackbarMessage != null) { - launch(Dispatchers.Main) { + launch { snackbarDispatcher.clear() } } diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts index 6d9ca1eb8e..4e8893aab6 100644 --- a/libraries/matrix/test/build.gradle.kts +++ b/libraries/matrix/test/build.gradle.kts @@ -26,4 +26,6 @@ dependencies { api(projects.libraries.core) api(projects.libraries.matrix.api) api(libs.coroutines.core) + implementation(libs.coroutines.test) + implementation(projects.tests.testutils) } 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 85c3555844..79a8186e1a 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 @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.test +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -36,15 +37,18 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, + private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), - override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), + override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), 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 index 508e6d5da4..4282860c99 100644 --- 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 @@ -16,40 +16,40 @@ package io.element.android.libraries.matrix.test.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 io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext -class FakeMediaLoader : MatrixMediaLoader { +class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader { var shouldFail = false - override suspend fun loadMediaContent(source: MediaSource): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun loadMediaContent(source: MediaSource): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(ByteArray(0)) + Result.success(ByteArray(0)) } } - override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(ByteArray(0)) + Result.success(ByteArray(0)) } } - override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(FakeMediaFile("")) + Result.success(FakeMediaFile("")) } } } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index 12481b81e1..62602a475b 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() { val baseDirectory = File(applicationContext.filesDir, "sessions") RustMatrixAuthenticationService( + context = applicationContext, baseDirectory = baseDirectory, coroutineScope = Singleton.appScope, coroutineDispatchers = Singleton.coroutineDispatchers, diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index fda44f46d1..d7c17c7895 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -28,12 +28,6 @@ android { dependencies { implementation(libs.test.junit) - implementation(libs.test.mockk) - implementation(libs.test.truth) - implementation(libs.test.turbine) implementation(libs.coroutines.test) - implementation(projects.libraries.matrix.test) - implementation(projects.services.appnavstate.test) - implementation(projects.services.appnavstate.test) implementation(projects.libraries.core) } From e3457fb594fe5f3f4e7f7635b04b2b6eb37cfa7c Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 22:18:13 +0200 Subject: [PATCH 17/32] Media: clean up code --- .../features/messages/impl/media/local/LocalMedia.kt | 4 +--- .../messages/impl/media/local/LocalMediaFactory.kt | 2 +- .../features/messages/impl/media/viewer/MediaViewerView.kt | 7 +++---- .../libraries/matrix/api/media/MatrixMediaLoader.kt | 3 ++- libraries/ui-strings/src/main/res/values/localazy.xml | 1 + 5 files changed, 8 insertions(+), 9 deletions(-) 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 index f5a09def47..549842428a 100644 --- 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 @@ -26,6 +26,4 @@ import kotlinx.parcelize.Parcelize data class LocalMedia( val uri: Uri, val info: MediaInfo, -) : Parcelable { - -} +) : Parcelable 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 index 25ade89d91..36852a5a80 100644 --- 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 @@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.media.MediaFile interface LocalMediaFactory { /** - * This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo] + * This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo]. */ fun createFromMediaFile( mediaFile: MediaFile, 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 index 3373b56726..64beccfa42 100644 --- 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 @@ -64,7 +64,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData -import io.element.android.libraries.ui.strings.R.* import kotlinx.coroutines.delay import io.element.android.libraries.ui.strings.R as StringR @@ -181,7 +180,7 @@ private fun MediaViewerTopBar( eventSink(MediaViewerEvents.OpenWith) }, ) { - Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = string.action_share)) + Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = StringR.string.action_open_with)) } IconButton( enabled = actionsEnabled, @@ -189,7 +188,7 @@ private fun MediaViewerTopBar( eventSink(MediaViewerEvents.SaveOnDisk) }, ) { - Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = string.action_save)) + Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = StringR.string.action_save)) } IconButton( enabled = actionsEnabled, @@ -197,7 +196,7 @@ private fun MediaViewerTopBar( eventSink(MediaViewerEvents.Share) }, ) { - Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = string.action_share)) + Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = StringR.string.action_share)) } } ) 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 index a2e1c99572..8dd5c625d1 100644 --- 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 @@ -33,7 +33,8 @@ interface MatrixMediaLoader { /** * @param source to fetch the data for. - * @param mimeType: optional mime type + * @param mimeType: optional mime type. + * @param body: optional body which will be used to name the file. * @return a [Result] of [MediaFile] */ suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 83dd9bd6ec..43d19f2202 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -34,6 +34,7 @@ "No" "Not now" "OK" + "Open with" "Quick reply" "Quote" "Remove" From 4b704fe02c925cbc638755e16d75f1874288ae2d Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 22:55:15 +0200 Subject: [PATCH 18/32] Media : timeline file adjustment --- .../timeline/components/event/TimelineItemFileView.kt | 11 ++++++++--- .../libraries/designsystem/theme/components/Text.kt | 4 ++++ 2 files changed, 12 insertions(+), 3 deletions(-) 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 index 36aaa27be3..5bc6787e4a 100644 --- 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 @@ -20,8 +20,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Attachment @@ -61,10 +62,13 @@ fun TimelineItemFileView( Icon( imageVector = Icons.Outlined.Attachment, contentDescription = "OpenFile", - modifier = Modifier.size(16.dp).rotate(-45f), + modifier = Modifier + .size(16.dp) + .rotate(-45f), ) } - Column(modifier = Modifier.padding(horizontal = 8.dp),) { + Spacer(Modifier.width(8.dp)) + Column { Text( text = content.body, maxLines = 2, @@ -74,6 +78,7 @@ fun TimelineItemFileView( Text( text = content.fileExtensionAndSize, color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt index ef1f035fad..3a59788300 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt @@ -66,6 +66,7 @@ fun Text( lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, + minLines: Int = 1, maxLines: Int = Int.MAX_VALUE, onTextLayout: (TextLayoutResult) -> Unit = {}, style: TextStyle = LocalTextStyle.current @@ -84,6 +85,7 @@ fun Text( lineHeight = lineHeight, overflow = overflow, softWrap = softWrap, + minLines = minLines, maxLines = maxLines, onTextLayout = onTextLayout, style = style, @@ -105,6 +107,7 @@ fun Text( lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, + minLines: Int = 1, maxLines: Int = Int.MAX_VALUE, inlineContent: ImmutableMap = persistentMapOf(), onTextLayout: (TextLayoutResult) -> Unit = {}, @@ -124,6 +127,7 @@ fun Text( lineHeight = lineHeight, overflow = overflow, softWrap = softWrap, + minLines = minLines, maxLines = maxLines, inlineContent = inlineContent, onTextLayout = onTextLayout, From c7e25a6a4faf1c160ba8f9bad9e45d5d67f8a012 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 6 Jun 2023 21:21:14 +0000 Subject: [PATCH 19/32] Update screenshots --- ...up_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...up_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...up_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...up_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...up_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...up_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png | 3 +++ ...up_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png | 3 +++ ...melineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...melineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...melineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...elineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...elineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...elineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png | 4 ++-- 17 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 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_5,NEXUS_5,1.0,en].png create mode 100644 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_6,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.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 index d400660085..9657e72a1c 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e +size 395351 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 index d400660085..9657e72a1c 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e +size 395351 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 index d400660085..9657e72a1c 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e +size 395351 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 index d400660085..920cdf6e71 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:49d8a19399542ae40d10ca7f07369fa68f1f4fbca52a61ffe0cdfa6d7fe6409a +size 395361 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 index d400660085..fca921c50b 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +oid sha256:c1c1eedbab868e0c2501220293608572850e052f685f7076ec939b3f1a9abf27 +size 4464 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_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.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..92a9cc43ff --- /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_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af5875f111c8763508615860bd04f0feee42190da78dff3968ec5598f112fbdc +size 6388 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_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.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bb10b18924 --- /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_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3a5e2ff92eb70cfbb1f31fe895d5e3342dace80b62d49e9549ead5403c18998 +size 14994 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 index 8d56987170..a179702410 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_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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6efe12c845bdac4f297fae51e23302b046434c898d4886da0890020375621632 -size 10518 +oid sha256:8fd21177b0b12bed1327986f084e1ee53e317751e1a30585d5ac72a1c8f35593 +size 9646 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 index 22e3c24e17..d6b5462836 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_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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d444880e0593058b55baed32e06231619575ce25ac5fb955da88b59a185d70f -size 13029 +oid sha256:1cc95f45e3761a7d988ce4e3708fa92d2544e3f652f435c86dd261c9ca6f31f0 +size 12130 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 index 8db2cadb53..f6621cd56a 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_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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b6ed694e25efd8293996045a36e9da57a3a9a971fbb2c233dc8755885f1981e5 -size 23613 +oid sha256:5f53fe35923250b86a635b48bf8d4ee5e79df1a7844fdc3db5a847651547e1a4 +size 23189 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 index 756a3ce487..ce2ebcd33f 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_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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a4ca40778073bdae1fa023751e9bb2de2d0e0dbdd4d08a1e978ab07b1f9c37a -size 9937 +oid sha256:ce311b7115be9f4de478fbb84c112a11d59e1dd7da1627774802902a8aa61a28 +size 9176 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 index 1cad701025..b14dfa2b4b 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_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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:80eda21a7258ecd15c8770c528a5c786a0d68c610e1bc34a45cb13597932dc75 -size 12070 +oid sha256:463a8a5a05102224eccc552ece41084b1b355682f684c4a063d614a0928d810e +size 11309 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 index 63365a9e39..f0be03f4c8 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_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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a2bfd6994de21f68e162a1cdfe75d0c82f6693ef2fbbaa0dc740a6ee6d2a466 -size 21193 +oid sha256:8e80b3348008def046f743af8f440b03612a163040f16aac4d0dac41632ff171 +size 20833 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 453308ca1b..74bd95f3b6 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:21426c10fe7c8b13628f2c15bd437ca6a727ed9baa4c85f5ef2a9a0e32e42139 -size 62722 +oid sha256:d9338c43f771fc2a04c825d40358b01bd4ad3dca2512e3a12408fd2d345e8745 +size 61796 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 ad62e92e3e..ae405dfff1 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:62bc8700035bd87c9c7045c1b963e72c1ba3970c8232d8dbc9ba31df892a91af -size 73999 +oid sha256:68b16f1231e3f328a30458956f395bcbe77afecee2075b07d88dd326beff282a +size 73031 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 14259d95f3..649d3cde2b 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:354e63f30121add1d2431da33645be1fb5a813fb90dd757c381d866904aa232b -size 62593 +oid sha256:9a46df297336d5224b7e08a52038999ad31d947ecf980b644f3ac988a69de773 +size 61818 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 12e3c4b419..b4dea5215a 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:b51fad2dd74f6e7b59c6df8e262b07523ea920bda514b2eb1442f0e65a1fcebb -size 74415 +oid sha256:2c420df941878750c090726a8f9a6a2e6f7fdb2ac85aa63321f9efe77032213f +size 73234 From b72291a91d33c6f1b13f28462e33f612b71f2799 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 23:43:02 +0200 Subject: [PATCH 20/32] Gradle: re-enable caching (will be handled in a separate PR) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 9b4f41f685..ae25b1ed02 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,7 +35,7 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -org.gradle.caching=false +org.gradle.caching=true org.gradle.configureondemand=true org.gradle.parallel=true From ee1571590f9742ae789ed3ced572acf3c737536d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Jun 2023 09:06:46 +0200 Subject: [PATCH 21/32] Lazy use the OkHttpClient. --- .../io/element/android/libraries/network/RetrofitFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt index 3873101c97..dc8b664764 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt @@ -32,6 +32,6 @@ class RetrofitFactory @Inject constructor( fun create(baseUrl: String): Retrofit = Retrofit.Builder() .baseUrl(baseUrl.ensureTrailingSlash()) .addConverterFactory(json.get().asConverterFactory("application/json".toMediaType())) - .callFactory(okHttpClient.get()) + .callFactory { request -> okHttpClient.get().newCall(request) } .build() } From 6910984588d71cde14b5593ce5f8587a736e621d Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Wed, 7 Jun 2023 11:10:29 +0100 Subject: [PATCH 22/32] Fix changing room avatar from details screen The presenter was expecting the MediaProcessor to return a MediaUploadInfo.Image, but it actually returns MediaUploadInfo.AnyFile because we're not compressing avatars (so it doesn't process the file and return more detailed info). This check/cast was entirely pointless, so change to just working on whatever we're given. The pickers constrain which types of file the user select, so we should be reasonably happy the files are images. Also actually log error details when updating the details, so we know what's going wrong. Closes #550 --- .../impl/edit/RoomDetailsEditPresenter.kt | 26 +++++++++++-------- .../edit/RoomDetailsEditPresenterTest.kt | 10 ++----- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt index a6726f9105..615541a3de 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -29,19 +29,19 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.net.toUri -import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.execute import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor -import io.element.android.libraries.mediaupload.api.MediaUploadInfo import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject class RoomDetailsEditPresenter @Inject constructor( @@ -139,13 +139,19 @@ class RoomDetailsEditPresenter @Inject constructor( val results = mutableListOf>() suspend { if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) { - results.add(room.setTopic(topic.orEmpty())) + results.add(room.setTopic(topic.orEmpty()).onFailure { + Timber.e(it, "Failed to set room topic") + }) } if (name.isNotEmpty() && name.trim() != room.name.orEmpty().trim()) { - results.add(room.setName(name)) + results.add(room.setName(name).onFailure { + Timber.e(it, "Failed to set room name") + }) } if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) { - results.add(updateAvatar(avatarUri)) + results.add(updateAvatar(avatarUri).onFailure { + Timber.e(it, "Failed to update avatar") + }) } if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow() }.execute(action) @@ -153,14 +159,12 @@ class RoomDetailsEditPresenter @Inject constructor( private suspend fun updateAvatar(avatarUri: Uri?): Result { return runCatching { - val result = if (avatarUri != null) { - val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() as? MediaUploadInfo.Image - val byteArray = preprocessed?.file?.readBytes() - byteArray?.let { room.updateAvatar(MimeTypes.Jpeg, it) } ?: error("Could not process the given uri ($avatarUri)") + if (avatarUri != null) { + val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() + room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() } else { - room.removeAvatar() + room.removeAvatar().getOrThrow() } - result.getOrThrow() } } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt index adbec76ae7..c1d098dab5 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt @@ -21,7 +21,6 @@ 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.libraries.matrix.ui.media.AvatarAction import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter @@ -29,9 +28,9 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo -import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.mockk.every import io.mockk.mockk @@ -600,14 +599,9 @@ class RoomDetailsEditPresenterTest { } fakePickerProvider.givenResult(anotherAvatarUri) - fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image( + fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.AnyFile( file = processedFile, info = mockk(), - thumbnailInfo = ThumbnailProcessingInfo( - file = processedFile, - info = mockk(), - blurhash = "", - ) ))) } From 06d397a610d5686939abcba36ebafc83e9ef4986 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Jun 2023 12:11:38 +0000 Subject: [PATCH 23/32] Update dependency io.sentry:sentry-android to v6.22.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 117b489775..f3db800641 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -153,7 +153,7 @@ telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", v # Analytics posthog = "com.posthog.android:posthog:2.0.3" -sentry_android = "io.sentry:sentry-android:6.21.0" +sentry_android = "io.sentry:sentry-android:6.22.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT" # Di From 865d52154539c5d89c78ea468023a20eaf6fce4d Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Jun 2023 15:41:07 +0200 Subject: [PATCH 24/32] Media: address PR review --- build.gradle.kts | 1 + .../impl/media/helper/fileExtensionAndSize.kt | 12 ++- .../impl/media/local/LocalMediaView.kt | 91 ++++++++++--------- .../impl/media/viewer/MediaViewerPresenter.kt | 59 +++++------- .../media/viewer/MediaViewerStateProvider.kt | 4 + .../impl/media/viewer/MediaViewerView.kt | 53 ++++++----- .../components/event/TimelineItemFileView.kt | 2 + 7 files changed, 120 insertions(+), 102 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7ddcc3dc86..6e34d336bc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -224,6 +224,7 @@ koverMerged { excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*" excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*" excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState" + excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState" } bound { minValue = 90 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt index 43d6ba2a9b..fcf64eb24f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt @@ -16,10 +16,18 @@ package io.element.android.features.messages.impl.media.helper +import android.webkit.MimeTypeMap + fun formatFileExtensionAndSize(name: String, size: String?): String { - val fileExtension = name.substringAfterLast('.', "").uppercase() + val fileExtension = name.substringAfterLast('.', "") + // Makes sure the extension is known by the system, otherwise default to binary extension. + val safeExtension = if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) { + fileExtension.uppercase() + } else { + "BIN" + } return buildString { - append(fileExtension) + append(safeExtension) if (size != null) { append(' ') append("($size)") 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 index 502e10e0a4..9683be9aeb 100644 --- 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 @@ -34,7 +34,11 @@ import androidx.compose.material.icons.outlined.Attachment import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +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.draw.clip @@ -70,40 +74,52 @@ import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState import me.saket.telephoto.zoomable.rememberZoomableState +@Stable +class LocalMediaViewState { + var isReady: Boolean by mutableStateOf(false) +} + +@Composable +fun rememberLocalMediaViewState(): LocalMediaViewState { + return remember { + LocalMediaViewState() + } +} + @SuppressLint("UnsafeOptInUsageError") @Composable fun LocalMediaView( localMedia: LocalMedia?, modifier: Modifier = Modifier, - info: MediaInfo? = localMedia?.info, - onReady: () -> Unit = {}, + localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), + mediaInfo: MediaInfo? = localMedia?.info, ) { val zoomableState = rememberZoomableState( zoomSpec = ZoomSpec(maxZoomFactor = 5f) ) - val mimeType = info?.mimeType + val mimeType = mediaInfo?.mimeType when { mimeType.isMimeTypeImage() -> MediaImageView( + localMediaViewState = localMediaViewState, localMedia = localMedia, zoomableState = zoomableState, - onReady = onReady, modifier = modifier ) mimeType.isMimeTypeVideo() -> MediaVideoView( + localMediaViewState = localMediaViewState, localMedia = localMedia, - onReady = onReady, modifier = modifier ) mimeType == MimeTypes.Pdf -> MediaPDFView( + localMediaViewState = localMediaViewState, localMedia = localMedia, zoomableState = zoomableState, - onReady = onReady, modifier = modifier ) else -> MediaFileView( + localMediaViewState = localMediaViewState, uri = localMedia?.uri, - info = info, - onReady = onReady, + info = mediaInfo, modifier = modifier ) } @@ -111,9 +127,9 @@ fun LocalMediaView( @Composable private fun MediaImageView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, zoomableState: ZoomableState, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { if (LocalInspectionMode.current) { @@ -124,11 +140,7 @@ private fun MediaImageView( ) } else { val zoomableImageState = rememberZoomableImageState(zoomableState) - LaunchedEffect(zoomableImageState.isImageDisplayed) { - if (zoomableImageState.isImageDisplayed) { - onReady() - } - } + localMediaViewState.isReady = zoomableImageState.isImageDisplayed ZoomableAsyncImage( modifier = modifier.fillMaxSize(), state = zoomableImageState, @@ -142,14 +154,14 @@ private fun MediaImageView( @UnstableApi @Composable fun MediaVideoView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val playerListener = object : Player.Listener { override fun onRenderedFirstFrame() { - onReady() + localMediaViewState.isReady = true } } val exoPlayer = remember { @@ -196,35 +208,27 @@ fun MediaVideoView( @Composable fun MediaPDFView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, zoomableState: ZoomableState, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { val pdfViewerState = rememberPdfViewerState( model = localMedia?.uri, zoomableState = zoomableState ) - LaunchedEffect(pdfViewerState.isLoaded) { - if (pdfViewerState.isLoaded) { - onReady() - } - } + localMediaViewState.isReady = pdfViewerState.isLoaded PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) } @Composable fun MediaFileView( + localMediaViewState: LocalMediaViewState, uri: Uri?, info: MediaInfo?, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { - LaunchedEffect(Unit) { - if(uri != null) { - onReady() - } - } + localMediaViewState.isReady = uri != null Box(modifier = modifier, contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( @@ -236,26 +240,29 @@ fun MediaFileView( ) { Icon( imageVector = Icons.Outlined.Attachment, - contentDescription = "OpenFile", + contentDescription = null, tint = MaterialTheme.colorScheme.background, modifier = Modifier .size(32.dp) .rotate(-45f), ) } - if(info == null) return - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = info.name, - maxLines = 2, - fontSize = 16.sp, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = formatFileExtensionAndSize(info.name, info.formattedFileSize), - fontSize = 14.sp, - ) + if (info != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = info.name, + maxLines = 2, + fontSize = 16.sp, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.name, info.formattedFileSize), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } 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 index 816e3c5acb..88bf7c91c2 100644 --- 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 @@ -116,46 +116,37 @@ class MediaViewerPresenter @AssistedInject constructor( } private fun CoroutineScope.saveOnDisk(localMedia: Async) = launch { - when (localMedia) { - is Async.Success -> { - localMediaActions.saveOnDisk(localMedia.state) - .onSuccess { - val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) - snackbarDispatcher.post(snackbarMessage) - } - .onFailure { - val snackbarMessage = SnackbarMessage(mediaActionsError(it)) - snackbarDispatcher.post(snackbarMessage) - } - } - else -> Unit - } + if (localMedia is Async.Success) { + localMediaActions.saveOnDisk(localMedia.state) + .onSuccess { + val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) + snackbarDispatcher.post(snackbarMessage) + } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } else Unit } private fun CoroutineScope.share(localMedia: Async) = launch { - when (localMedia) { - is Async.Success -> { - localMediaActions.share(localMedia.state) - .onFailure { - val snackbarMessage = SnackbarMessage(mediaActionsError(it)) - snackbarDispatcher.post(snackbarMessage) - } - } - else -> Unit - } + if (localMedia is Async.Success) { + localMediaActions.share(localMedia.state) + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } else Unit } private fun CoroutineScope.open(localMedia: Async) = launch { - when (localMedia) { - is Async.Success -> { - localMediaActions.open(localMedia.state) - .onFailure { - val snackbarMessage = SnackbarMessage(mediaActionsError(it)) - snackbarDispatcher.post(snackbarMessage) - } - } - else -> Unit - } + if (localMedia is Async.Success) { + localMediaActions.open(localMedia.state) + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } else Unit } private fun mediaActionsError(throwable: Throwable): Int { 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 index 2705072f0d..786ec984b7 100644 --- 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 @@ -50,6 +50,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider ), aPdfInfo(), ), + aMediaViewerState( + Async.Loading(), + aFileInfo(), + ), aMediaViewerState( Async.Success( LocalMedia(Uri.EMPTY, aFileInfo()) 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 index 64beccfa42..afbb9bb331 100644 --- 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 @@ -46,12 +46,15 @@ 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.platform.LocalInspectionMode 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 coil.compose.AsyncImage +import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaView +import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.designsystem.components.button.BackButton @@ -82,28 +85,9 @@ fun MediaViewerView( 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 - } - + val localMediaViewState = rememberLocalMediaViewState() + val showThumbnail = !localMediaViewState.isReady + val showProgress = rememberShowProgress(state.downloadedMedia) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) Scaffold( @@ -151,9 +135,9 @@ fun MediaViewerView( ) } LocalMediaView( + localMediaViewState = localMediaViewState, localMedia = state.downloadedMedia.dataOrNull(), - info = state.mediaInfo, - onReady = ::onMediaReady + mediaInfo = state.mediaInfo, ) ThumbnailView( thumbnailSource = state.thumbnailSource, @@ -164,6 +148,27 @@ fun MediaViewerView( } } +@Composable +private fun rememberShowProgress(downloadedMedia: Async): Boolean { + var showProgress by remember { + mutableStateOf(false) + } + if (LocalInspectionMode.current) { + showProgress = downloadedMedia.isLoading() + } else { + // 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(downloadedMedia) { + showProgress = false + delay(100) + if (downloadedMedia.isLoading()) { + showProgress = true + } + } + } + return showProgress +} + @Composable private fun MediaViewerTopBar( actionsEnabled: Boolean, 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 index 5bc6787e4a..b785734296 100644 --- 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 @@ -79,6 +79,8 @@ fun TimelineItemFileView( text = content.fileExtensionAndSize, color = MaterialTheme.colorScheme.secondary, fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } From c69c3ea942fff281c434f1fa02a4e56ffba6ec73 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Jun 2023 15:55:16 +0200 Subject: [PATCH 25/32] Media: make some minor UI fixes --- .../preview/AttachmentsPreviewStateProvider.kt | 10 ++++++++-- .../attachments/preview/AttachmentsPreviewView.kt | 3 ++- .../messages/impl/media/local/LocalMediaView.kt | 12 +++++++++--- .../features/messages/impl/media/local/MediaInfo.kt | 8 ++++---- 4 files changed, 23 insertions(+), 10 deletions(-) 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 index 3724bb7f68..58fea4a4f2 100644 --- 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 @@ -20,6 +20,9 @@ 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.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.aFileInfo +import io.element.android.features.messages.impl.media.local.aVideoInfo import io.element.android.features.messages.impl.media.local.anImageInfo import io.element.android.libraries.architecture.Async @@ -27,14 +30,17 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider get() = sequenceOf( anAttachmentsPreviewState(), + anAttachmentsPreviewState(mediaInfo = aFileInfo()), anAttachmentsPreviewState(sendActionState = Async.Loading()), anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())), ) } -fun anAttachmentsPreviewState(sendActionState: Async = Async.Uninitialized) = AttachmentsPreviewState( +fun anAttachmentsPreviewState( + mediaInfo: MediaInfo = anImageInfo(), + sendActionState: Async = Async.Uninitialized) = AttachmentsPreviewState( attachment = Attachment.Media( - localMedia = LocalMedia("path".toUri(), anImageInfo()), + localMedia = LocalMedia("file://path".toUri(), mediaInfo), compressIfPossible = true ), sendActionState = sendActionState, 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 index 28c78da1be..cf69ec169c 100644 --- 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 @@ -125,7 +125,8 @@ private fun AttachmentPreviewContent( Box( modifier = Modifier .fillMaxWidth() - .weight(1f) + .weight(1f), + contentAlignment = Alignment.Center, ) { when (attachment) { is Attachment.Media -> LocalMediaView( 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 index 9683be9aeb..e65971d2b3 100644 --- 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -47,6 +48,7 @@ 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.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -65,6 +67,7 @@ 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.R +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.designsystem.utils.OnLifecycleEvent @@ -229,7 +232,7 @@ fun MediaFileView( modifier: Modifier = Modifier, ) { localMediaViewState.isReady = uri != null - Box(modifier = modifier, contentAlignment = Alignment.Center) { + Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier @@ -248,12 +251,14 @@ fun MediaFileView( ) } if (info != null) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) Text( text = info.name, maxLines = 2, fontSize = 16.sp, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = ElementTheme.colors.gray1400 ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -261,6 +266,7 @@ fun MediaFileView( fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.gray1400 ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt index 889a169365..57cd788bb8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt @@ -28,17 +28,17 @@ data class MediaInfo( ) : Parcelable fun anImageInfo(): MediaInfo = MediaInfo( - "an image file", MimeTypes.Jpeg, "4MB" + "an image file.jpg", MimeTypes.Jpeg, "4MB" ) fun aVideoInfo(): MediaInfo = MediaInfo( - "a video file", MimeTypes.Mp4, "14MB" + "a video file.mp4", MimeTypes.Mp4, "14MB" ) fun aPdfInfo(): MediaInfo = MediaInfo( - "a pdf file", MimeTypes.Pdf, "23MB" + "a pdf file.pdf", MimeTypes.Pdf, "23MB" ) fun aFileInfo(): MediaInfo = MediaInfo( - "an apk file", MimeTypes.Apk, "50MB" + "an apk file.apk", MimeTypes.Apk, "50MB" ) From a2fc02f90e5bb3c7100c081ce8a7bf38c270ebbe Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Jun 2023 15:58:05 +0200 Subject: [PATCH 26/32] Media: extract LocalMediaViewState to his own file --- .../impl/media/local/LocalMediaView.kt | 16 --------- .../impl/media/local/LocalMediaViewState.kt | 36 +++++++++++++++++++ 2 files changed, 36 insertions(+), 16 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt 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 index e65971d2b3..13f0568511 100644 --- 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 @@ -35,11 +35,7 @@ import androidx.compose.material.icons.outlined.Attachment import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -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.draw.clip @@ -77,18 +73,6 @@ import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState import me.saket.telephoto.zoomable.rememberZoomableState -@Stable -class LocalMediaViewState { - var isReady: Boolean by mutableStateOf(false) -} - -@Composable -fun rememberLocalMediaViewState(): LocalMediaViewState { - return remember { - LocalMediaViewState() - } -} - @SuppressLint("UnsafeOptInUsageError") @Composable fun LocalMediaView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt new file mode 100644 index 0000000000..e009c3f6cc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt @@ -0,0 +1,36 @@ +/* + * 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 androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +@Stable +class LocalMediaViewState { + var isReady: Boolean by mutableStateOf(false) +} + +@Composable +fun rememberLocalMediaViewState(): LocalMediaViewState { + return remember { + LocalMediaViewState() + } +} From 35aca5e6c7689855271a78a0c00899e1487ecaac Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 7 Jun 2023 14:10:32 +0000 Subject: [PATCH 27/32] Update screenshots --- ...chmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...chmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...chmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png | 3 +++ ...up_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...up_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png | 4 ++-- ...up_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png | 3 +++ 6 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 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_3,NEXUS_5,1.0,en].png create mode 100644 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_7,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.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 index dd25b23be8..bc5044cd40 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df0343130f7d53b6257178623a12bcb7bbbcb995cc47ddc43e21e2269e8e8ffe -size 183009 +oid sha256:ade6aa1e0fd7731173f2ae3424a931234d64e0aa8334a93c983ee46ff6dc5ce5 +size 17170 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 index 7db0c02d08..dd25b23be8 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607 -size 98742 +oid sha256:df0343130f7d53b6257178623a12bcb7bbbcb995cc47ddc43e21e2269e8e8ffe +size 183009 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_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.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7db0c02d08 --- /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_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607 +size 98742 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 index 9657e72a1c..4449898c52 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e -size 395351 +oid sha256:dd52b8ee709319d8b4e5fbc33888248975593cab1e45fb9e81b0e0178e529a66 +size 395357 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_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.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png index bb10b18924..289d08f536 100644 --- 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_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.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3a5e2ff92eb70cfbb1f31fe895d5e3342dace80b62d49e9549ead5403c18998 -size 14994 +oid sha256:6c8a175985e271948423d677b0d79e9ae930aa227c417d5c78e567abda40e378 +size 16210 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_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.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b807b8219f --- /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_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:367e2b224070dcc8ceb218b17336cd406b650b66f9c0fff50f8ec6093bb174dd +size 16182 From eff03a86fb646408203a324a08749ef6be1b1b20 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Jun 2023 17:02:08 +0200 Subject: [PATCH 28/32] Fix bad namespace. There were a warning: Namespace 'io.element.android.libraries.matrix.test' used in: :libraries:matrix:test, :libraries:mediaupload:test. --- libraries/mediaupload/test/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/mediaupload/test/build.gradle.kts b/libraries/mediaupload/test/build.gradle.kts index 6daa89b152..535ba51ac4 100644 --- a/libraries/mediaupload/test/build.gradle.kts +++ b/libraries/mediaupload/test/build.gradle.kts @@ -19,7 +19,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.test" + namespace = "io.element.android.libraries.mediaupload.test" } dependencies { From 555f0fe76fb89aad914987ce3072738616cdc169 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 7 Jun 2023 15:11:56 +0000 Subject: [PATCH 29/32] Update screenshots --- ...chmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index dd25b23be8..27ad024712 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df0343130f7d53b6257178623a12bcb7bbbcb995cc47ddc43e21e2269e8e8ffe -size 183009 +oid sha256:846f4846bed07b1aa030c07fbde42bdf38f6329250e1641ad83a9bd8786ea683 +size 183396 From b4d16c4022ea006940f5c9d6b9ce75844218ae6c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Jun 2023 20:54:37 +0000 Subject: [PATCH 30/32] Update dependency androidx.compose:compose-bom to v2023.06.00 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 117b489775..2a3f8035d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ media3 = "1.0.2" browser = "1.5.0" # Compose -compose_bom = "2023.05.01" +compose_bom = "2023.06.00" composecompiler = "1.4.7" # Coroutines From 25c32cb1e8583c5918dcf2fa6a43f6485ec01bd4 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 8 Jun 2023 12:15:13 +0200 Subject: [PATCH 31/32] [Message actions] New UI for replies (#545) * Add 'reply to' UI to the message composer. * Move the `BlurHashAsyncImage` to `:libraries:designsystem` as it is now used in several modules. * Create reusable `AttachmentThumbnail` and associated data classes and enums, it's now added to `:libraries:matrixui`. * Re-use `AttachmentThumbnail` in a `ActionListView` and `TextComposer`. * Add 'inReplyTo' models and UI. * Add min size for images * Create a separate layout for media items with no reply to info. Also, separate `Timeline__Row` components from `TimelineView`, as it was getting too large. * Added `EqualWidthColumn` to use inside message bubbles. Also fixed some modifiers for media items replying to other messages. * Disable `inReplyToClicked`. * Remove unused resources and libraries. * Remove any traces of `BlurHashAsyncImage` in `:features:messages`, since it was moved to the design system. --------- Co-authored-by: ElementBot --- .../messages/impl/MessagesPresenter.kt | 52 ++- .../impl/actionlist/ActionListView.kt | 93 ++--- .../impl/timeline/TimelineStateProvider.kt | 3 + .../messages/impl/timeline/TimelineView.kt | 249 +----------- .../components/TimelineItemEventRow.kt | 361 ++++++++++++++++++ .../components/TimelineItemStateEventRow.kt | 67 ++++ .../components/TimelineItemVirtualRow.kt | 37 ++ .../components/event/TimelineItemImageView.kt | 7 +- .../components/event/TimelineItemVideoView.kt | 2 +- .../event/TimelineItemEventFactory.kt | 1 + .../impl/timeline/model/TimelineItem.kt | 2 + .../messagesummary/MessageSummaryFormatter.kt | 23 ++ .../MessageSummaryFormatterImpl.kt | 53 +++ .../messages/MessagesPresenterTest.kt | 108 ++++++ .../actionlist/ActionListPresenterTest.kt | 3 +- .../messages/fixtures/aMessageEvent.kt | 11 +- .../MessageComposerPresenterTest.kt | 2 +- .../groups/TimelineItemGrouperTest.kt | 3 +- .../FakeMessageSummaryFormatter.kt | 31 ++ libraries/designsystem/build.gradle.kts | 1 + .../components}/BlurHashAsyncImage.kt | 2 +- .../components/EqualWidthColumn.kt | 58 +++ .../api/timeline/item/event/EventContent.kt | 16 +- .../timeline/item/event/EventTimelineItem.kt | 10 +- .../impl/timeline/MatrixTimelineItemMapper.kt | 22 ++ .../impl/timeline/RustMatrixTimeline.kt | 2 + .../timeline/item/event/EventMessageMapper.kt | 23 +- .../ui/components/AttachmentThumbnail.kt | 93 +++++ libraries/textcomposer/build.gradle.kts | 7 +- .../textcomposer/src/main/AndroidManifest.xml | 20 - .../textcomposer/MessageComposerMode.kt | 2 + .../libraries/textcomposer/TextComposer.kt | 225 ++++++++++- .../res/drawable/bg_rich_text_menu_button.xml | 34 -- .../main/res/drawable/bottomsheet_handle.xml | 23 -- .../main/res/drawable/ic_composer_bold.xml | 26 -- .../res/drawable/ic_composer_collapse.xml | 25 -- .../res/drawable/ic_composer_full_screen.xml | 25 -- .../main/res/drawable/ic_composer_italic.xml | 26 -- .../ic_composer_rich_text_editor_close.xml | 25 -- .../ic_composer_rich_text_editor_edit.xml | 28 -- .../drawable/ic_composer_rich_text_save.xml | 32 -- .../drawable/ic_composer_strikethrough.xml | 28 -- .../res/drawable/ic_composer_underlined.xml | 28 -- .../src/main/res/drawable/ic_quote.xml | 30 -- .../res/drawable/ic_rich_composer_add.xml | 31 -- .../res/drawable/ic_rich_composer_send.xml | 28 -- .../res/layout/composer_rich_text_layout.xml | 232 ----------- .../res/layout/view_rich_text_menu_button.xml | 27 -- ...oserDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 - ...EditDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...ditLightPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...serLightPreview_0_null,NEXUS_5,1.0,en].png | 3 - ...eplyDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...plyLightPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...mpleDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...pleLightPreview_0_null,NEXUS_5,1.0,en].png | 3 + 56 files changed, 1253 insertions(+), 1008 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt rename {features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash => libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components}/BlurHashAsyncImage.kt (97%) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt delete mode 100644 libraries/textcomposer/src/main/AndroidManifest.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_quote.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml delete mode 100644 libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml delete mode 100644 libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml delete mode 100644 libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml delete mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png delete mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png 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 cbaeb26c93..015e76f448 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 @@ -33,16 +33,26 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer 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 +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.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 +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.textcomposer.MessageComposerMode -import io.element.android.features.networkmonitor.api.NetworkMonitor -import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.handleSnackbarMessage +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -55,6 +65,7 @@ class MessagesPresenter @Inject constructor( private val actionListPresenter: ActionListPresenter, private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, + private val messageSummaryFormatter: MessageSummaryFormatter, ) : Presenter { @Composable @@ -145,7 +156,38 @@ class MessagesPresenter @Inject constructor( private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { if (targetEvent.eventId == null) return - val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.eventId, "") + val textContent = messageSummaryFormatter.format(targetEvent) + val attachmentThumbnailInfo = when (targetEvent.content) { + is TimelineItemImageContent -> AttachmentThumbnailInfo( + mediaSource = targetEvent.content.mediaSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.Image, + blurHash = targetEvent.content.blurhash, + ) + is TimelineItemVideoContent -> AttachmentThumbnailInfo( + mediaSource = targetEvent.content.thumbnailSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.Video, + blurHash = targetEvent.content.blurHash, + ) + is TimelineItemFileContent -> AttachmentThumbnailInfo( + mediaSource = targetEvent.content.thumbnailSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.File, + blurHash = null, + ) + is TimelineItemTextBasedContent, + is TimelineItemRedactedContent, + is TimelineItemStateContent, + is TimelineItemEncryptedContent, + is TimelineItemUnknownContent -> null + } + val composerMode = MessageComposerMode.Reply( + senderName = targetEvent.safeSenderName, + eventId = targetEvent.eventId, + attachmentThumbnailInfo = attachmentThumbnailInfo, + defaultContent = textContent, + ) composerState.eventSink( MessageComposerEvents.SetMode(composerMode) ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 86d957e777..d7222be960 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -16,7 +16,6 @@ package io.element.android.features.messages.impl.actionlist -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -36,19 +35,16 @@ import androidx.compose.material.ListItem import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddReaction -import androidx.compose.material.icons.outlined.Attachment -import androidx.compose.material.icons.outlined.VideoCameraBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -56,7 +52,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction -import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -67,6 +62,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -75,8 +71,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet -import io.element.android.libraries.matrix.ui.media.MediaRequestData -import io.element.android.libraries.ui.strings.R as StringR +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -189,70 +186,56 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis) } + val context = LocalContext.current + val formatter = remember(context) { MessageSummaryFormatterImpl(context) } + val textContent = remember(event.content) { formatter.format(event) } + when (event.content) { - is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) } - is TimelineItemStateContent -> content = { ContentForBody(event.content.body) } - is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) } - is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) } - is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) } - is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) } + is TimelineItemTextBasedContent, + is TimelineItemStateContent, + is TimelineItemProfileChangeContent, + is TimelineItemEncryptedContent, + is TimelineItemRedactedContent, + is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemImageContent -> { icon = { - val mediaRequestData = MediaRequestData( - source = event.content.mediaSource, - kind = MediaRequestData.Kind.Thumbnail(32), - ) - BlurHashAsyncImage( - model = mediaRequestData, - blurHash = event.content.blurhash, - contentDescription = stringResource(StringR.string.common_image), - contentScale = ContentScale.Crop, + AttachmentThumbnail( modifier = imageModifier, + info = AttachmentThumbnailInfo( + mediaSource = event.content.mediaSource, + textContent = textContent, + type = AttachmentThumbnailType.File, + blurHash = event.content.blurhash, + ) ) } content = { ContentForBody(event.content.body) } } is TimelineItemVideoContent -> { icon = { - val thumbnailSource = event.content.thumbnailSource - if (thumbnailSource != null) { - val mediaRequestData = MediaRequestData( - source = event.content.thumbnailSource, - kind = MediaRequestData.Kind.Thumbnail(32), - ) - BlurHashAsyncImage( - model = mediaRequestData, + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + mediaSource = event.content.thumbnailSource, + textContent = textContent, + type = AttachmentThumbnailType.Video, blurHash = event.content.blurHash, - contentDescription = stringResource(StringR.string.common_video), - contentScale = ContentScale.Crop, - modifier = imageModifier, ) - } else { - Box( - modifier = imageModifier.background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Outlined.VideoCameraBack, - contentDescription = stringResource(StringR.string.common_video), - ) - } - } + ) } content = { ContentForBody(event.content.body) } } is TimelineItemFileContent -> { icon = { - Box( - modifier = imageModifier.background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Outlined.Attachment, - contentDescription = stringResource(StringR.string.common_file), - modifier = Modifier.rotate(-45f) + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + mediaSource = null, + textContent = textContent, + type = AttachmentThumbnailType.File, + blurHash = null ) - } + ) } content = { ContentForBody(event.content.body) } } 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 2cee290871..7e23657974 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 @@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlin.random.Random @@ -96,6 +97,7 @@ internal fun aTimelineItemEvent( content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, sendState: EventSendState = EventSendState.Sent(eventId), + inReplyTo: InReplyTo? = null, ): TimelineItem.Event { return TimelineItem.Event( id = eventId.value, @@ -113,5 +115,6 @@ internal fun aTimelineItemEvent( senderDisplayName = "sender", groupPosition = groupPosition, sendState = sendState, + inReplyTo = inReplyTo, ) } 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 f5ebdcc796..f8717517f4 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,30 +17,16 @@ package io.element.android.features.messages.impl.timeline import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding 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.material3.MaterialTheme @@ -55,39 +41,24 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshotFlow 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.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -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.TimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView -import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView -import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator 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.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.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.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.core.EventId import io.element.android.libraries.matrix.api.core.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.distinctUntilChanged @@ -101,12 +72,16 @@ fun TimelineView( onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, ) { - fun onReachedLoadMore() { state.eventSink(TimelineEvents.LoadMore) } val lazyListState = rememberLazyListState() + + fun inReplyToClicked(eventId: EventId) { + // TODO implement this logic once we have support to 'jump to event X' in sliding sync + } + Box(modifier = modifier) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -124,6 +99,7 @@ fun TimelineView( onClick = onMessageClicked, onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked, + inReplyToClick = ::inReplyToClicked, ) if (index == state.timelineItems.lastIndex) { onReachedLoadMore() @@ -146,6 +122,7 @@ fun TimelineItemRow( onUserDataClick: (UserId) -> Unit, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, modifier: Modifier = Modifier ) { when (timelineItem) { @@ -179,6 +156,7 @@ fun TimelineItemRow( onClick = ::onClick, onLongClick = ::onLongClick, onUserDataClick = onUserDataClick, + inReplyToClick = inReplyToClick, modifier = modifier, ) } @@ -209,6 +187,7 @@ fun TimelineItemRow( highlightedItem = highlightedItem, onClick = onClick, onLongClick = onLongClick, + inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, ) } @@ -219,208 +198,6 @@ fun TimelineItemRow( } } -@Composable -fun TimelineItemVirtualRow( - virtual: TimelineItem.Virtual, - modifier: Modifier = Modifier -) { - when (virtual.model) { - is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier) - is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) - else -> return - } -} - -@Composable -fun TimelineItemEventRow( - event: TimelineItem.Event, - isHighlighted: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - onUserDataClick: (UserId) -> Unit, - modifier: Modifier = Modifier -) { - val interactionSource = remember { MutableInteractionSource() } - - fun onUserDataClicked() { - onUserDataClick(event.senderId) - } - - val (parentAlignment, contentAlignment) = if (event.isMine) { - Pair(Alignment.CenterEnd, Alignment.End) - } else { - Pair(Alignment.CenterStart, Alignment.Start) - } - - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(), - contentAlignment = parentAlignment - ) { - Row { - if (!event.isMine) { - Spacer(modifier = Modifier.width(4.dp)) - } - Column(horizontalAlignment = contentAlignment) { - if (event.showSenderInformation) { - MessageSenderInformation( - event.safeSenderName, - event.senderAvatar, - Modifier - .zIndex(1f) - .offset(y = 12.dp) - .clickable(onClick = ::onUserDataClicked) - ) - } - val bubbleState = BubbleState( - groupPosition = event.groupPosition, - isMine = event.isMine, - isHighlighted = isHighlighted, - ) - MessageEventBubble( - state = bubbleState, - interactionSource = interactionSource, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier - .zIndex(-1f) - .widthIn(max = 320.dp) - ) { - MessageEventBubbleContent( - event = event, - interactionSource = interactionSource, - onMessageClick = onClick, - onMessageLongClick = onLongClick - ) - } - TimelineItemReactionsView( - reactionsState = event.reactionsState, - modifier = Modifier - .zIndex(1f) - .offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp)) - ) - } - if (event.isMine) { - Spacer(modifier = Modifier.width(16.dp)) - } - } - } - if (event.groupPosition.isNew()) { - Spacer(modifier = modifier.height(8.dp)) - } else { - Spacer(modifier = modifier.height(2.dp)) - } -} - -@Composable -fun TimelineItemStateEventRow( - event: TimelineItem.Event, - isHighlighted: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier -) { - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(), - contentAlignment = Alignment.Center - ) { - MessageStateEventContainer( - isHighlighted = isHighlighted, - interactionSource = interactionSource, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier - .zIndex(-1f) - .widthIn(max = 320.dp) - ) { - TimelineItemEventContentView( - content = event.content, - interactionSource = interactionSource, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier.defaultTimelineContentPadding() - ) - } - } -} - -@Composable -fun MessageEventBubbleContent( - event: TimelineItem.Event, - interactionSource: MutableInteractionSource, - onMessageClick: () -> Unit, - onMessageLongClick: () -> Unit, - modifier: Modifier = Modifier -) { - 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 (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) - ) - } - } -} - -@Composable -private fun MessageSenderInformation( - sender: String, - senderAvatar: AvatarData?, - modifier: Modifier = Modifier -) { - Row(modifier = modifier) { - if (senderAvatar != null) { - Avatar(senderAvatar) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = sender, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .alignBy(LastBaseline) - ) - } -} - @Composable internal fun BoxScope.TimelineScrollHelper( lazyListState: LazyListState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt new file mode 100644 index 0000000000..25f5ff2399 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -0,0 +1,361 @@ +/* + * 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.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +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.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +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.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.components.EqualWidthColumn +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.theme.LocalColors +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +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.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType + +@Composable +fun TimelineItemEventRow( + event: TimelineItem.Event, + isHighlighted: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + onUserDataClick: (UserId) -> Unit, + inReplyToClick: (EventId) -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + + fun onUserDataClicked() { + onUserDataClick(event.senderId) + } + + fun inReplayToClicked() { + val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return + inReplyToClick(inReplyToEventId) + } + + val (parentAlignment, contentAlignment) = if (event.isMine) { + Pair(Alignment.CenterEnd, Alignment.End) + } else { + Pair(Alignment.CenterStart, Alignment.Start) + } + + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + contentAlignment = parentAlignment + ) { + Row { + if (!event.isMine) { + Spacer(modifier = Modifier.width(4.dp)) + } + Column(horizontalAlignment = contentAlignment) { + if (event.showSenderInformation) { + MessageSenderInformation( + event.safeSenderName, + event.senderAvatar, + Modifier + .zIndex(1f) + .offset(y = 12.dp) + .clickable(onClick = ::onUserDataClicked) + ) + } + val bubbleState = BubbleState( + groupPosition = event.groupPosition, + isMine = event.isMine, + isHighlighted = isHighlighted, + ) + MessageEventBubble( + state = bubbleState, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .zIndex(-1f) + .widthIn(max = 320.dp) + ) { + MessageEventBubbleContent( + event = event, + interactionSource = interactionSource, + onMessageClick = onClick, + onMessageLongClick = onLongClick, + inReplyToClick = ::inReplayToClicked, + ) + } + TimelineItemReactionsView( + reactionsState = event.reactionsState, + modifier = Modifier + .zIndex(1f) + .offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp)) + ) + } + if (event.isMine) { + Spacer(modifier = Modifier.width(16.dp)) + } + } + } + if (event.groupPosition.isNew()) { + Spacer(modifier = modifier.height(8.dp)) + } else { + Spacer(modifier = modifier.height(2.dp)) + } +} + +@Composable +private fun MessageSenderInformation( + sender: String, + senderAvatar: AvatarData?, + modifier: Modifier = Modifier +) { + Row(modifier = modifier) { + if (senderAvatar != null) { + Avatar(senderAvatar) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = sender, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .alignBy(LastBaseline) + ) + } +} + +@Composable +private fun MessageEventBubbleContent( + event: TimelineItem.Event, + interactionSource: MutableInteractionSource, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, + inReplyToClick: () -> Unit, + modifier: Modifier = Modifier +) { + val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent + val replyToDetails = event.inReplyTo as? InReplyTo.Ready + + @Composable + fun ContentView( + modifier: Modifier = Modifier + ) { + TimelineItemEventContentView( + content = event.content, + interactionSource = interactionSource, + onClick = onMessageClick, + onLongClick = onMessageLongClick, + modifier = modifier, + ) + } + + @Composable + fun ContentAndTimestampView( + overlayTimestamp: Boolean, + modifier: Modifier = Modifier, + contentModifier: Modifier = Modifier, + timestampModifier: Modifier = Modifier, + ) { + if (overlayTimestamp) { + Box(modifier) { + ContentView(modifier = contentModifier) + TimelineEventTimestampView( + event = event, + onClick = onMessageClick, + modifier = timestampModifier + .padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding + .background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp)) + .align(Alignment.BottomEnd) + .padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding + ) + } + } else { + Column(modifier) { + ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) + TimelineEventTimestampView( + event = event, + onClick = onMessageClick, + modifier = timestampModifier + .align(Alignment.End) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + } + + /** Used only for media items, with no reply to metadata. It displays the contents with no paddings. */ + @Composable + fun SimpleMediaItemLayout(modifier: Modifier = Modifier) { + ContentAndTimestampView(overlayTimestamp = true, modifier = modifier) + } + + /** Used for every other type of message, groups the different components in a Column with some space between them. */ + @Composable + fun CommonLayout( + inReplyToDetails: InReplyTo.Ready?, + modifier: Modifier = Modifier + ) { + EqualWidthColumn(modifier = modifier, spacing = 8.dp) { + if (inReplyToDetails != null) { + val senderName = event.senderDisplayName ?: event.senderId.value + val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails) + ReplyToContent( + senderName = senderName, + text = inReplyToDetails.content.body, + attachmentThumbnailInfo = attachmentThumbnailInfo, + modifier = Modifier + .padding(top = 8.dp, start = 8.dp, end = 8.dp) + .clickable(enabled = true, onClick = inReplyToClick), + ) + } + val modifierWithPadding = if (isMediaItem) { + Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + } else { + Modifier + } + + val contentModifier = if (isMediaItem) { + Modifier.clip(RoundedCornerShape(12.dp)) + } else { + Modifier + } + + ContentAndTimestampView( + overlayTimestamp = isMediaItem, + contentModifier = contentModifier, + modifier = modifierWithPadding, + ) + } + } + + if (isMediaItem && replyToDetails == null) { + SimpleMediaItemLayout() + } else { + CommonLayout(inReplyToDetails = replyToDetails, modifier = modifier) + } +} + +@Composable +private fun ReplyToContent( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + modifier: Modifier = Modifier, +) { + val paddings = if (attachmentThumbnailInfo != null) { + PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) + } else { + PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp) + } + Row( + modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Column(verticalArrangement = Arrangement.SpaceBetween) { + Text( + senderName, + style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium), + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = text.orEmpty(), + style = ElementTextStyles.Regular.caption1, + textAlign = TextAlign.Start, + color = LocalColors.current.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) = + when (val type = inReplyTo.content.type) { + is ImageMessageType -> AttachmentThumbnailInfo( + mediaSource = type.info?.thumbnailSource, + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.Image, + blurHash = type.info?.blurhash, + ) + is VideoMessageType -> AttachmentThumbnailInfo( + mediaSource = type.info?.thumbnailSource, + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.Video, + blurHash = type.info?.blurhash, + ) + is FileMessageType -> AttachmentThumbnailInfo( + mediaSource = type.info?.thumbnailSource, + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.File, + blurHash = null, + ) + else -> null + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt new file mode 100644 index 0000000000..7b0a16b9a8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -0,0 +1,67 @@ +/* + * 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.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding + +@Composable +fun TimelineItemStateEventRow( + event: TimelineItem.Event, + isHighlighted: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + MessageStateEventContainer( + isHighlighted = isHighlighted, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .zIndex(-1f) + .widthIn(max = 320.dp) + ) { + TimelineItemEventContentView( + content = event.content, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier.defaultTimelineContentPadding() + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt new file mode 100644 index 0000000000..13a6610ffe --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.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.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel + +@Composable +fun TimelineItemVirtualRow( + virtual: TimelineItem.Virtual, + modifier: Modifier = Modifier +) { + when (virtual.model) { + is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier) + is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) + else -> return + } +} 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 f2cb2b2b92..566e899a36 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 @@ -22,20 +22,23 @@ import androidx.compose.ui.Modifier 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.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage 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 +import kotlin.math.max @Composable fun TimelineItemImageView( content: TimelineItemImageContent, modifier: Modifier = Modifier, ) { + // TODO place this value somewhere else? + val minHeight = max(100, content.height ?: 0) TimelineItemAspectRatioBox( - height = content.height, + height = minHeight, aspectRatio = content.aspectRatio, modifier = modifier ) { 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 index 883f7b30f3..aa024e1033 100644 --- 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 @@ -29,9 +29,9 @@ 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.components.BlurHashAsyncImage import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 87792edf1d..ecd85a213a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -81,6 +81,7 @@ class TimelineItemEventFactory @Inject constructor( groupPosition = groupPosition, reactionsState = currentTimelineItem.computeReactionsState(), sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet, + inReplyTo = currentTimelineItem.event.inReplyTo(), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index c004901232..5a5f382a54 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import kotlinx.collections.immutable.ImmutableList @Immutable @@ -59,6 +60,7 @@ sealed interface TimelineItem { val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val reactionsState: TimelineItemReactions, val sendState: EventSendState, + val inReplyTo: InReplyTo?, ) : TimelineItem { val showSenderInformation = groupPosition.isNew() && !isMine diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt new file mode 100644 index 0000000000..241b1282a0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.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.utils.messagesummary + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +interface MessageSummaryFormatter { + fun format(event: TimelineItem.Event): String +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt new file mode 100644 index 0000000000..b924a9e7b4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.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.utils.messagesummary + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.impl.timeline.model.TimelineItem +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.TimelineItemProfileChangeContent +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 +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.ui.strings.R +import javax.inject.Inject + +@ContributesBinding(RoomScope::class) +class MessageSummaryFormatterImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : MessageSummaryFormatter { + override fun format(event: TimelineItem.Event): String { + return when (event.content) { + is TimelineItemTextBasedContent -> event.content.body + is TimelineItemStateContent -> event.content.body + is TimelineItemProfileChangeContent -> event.content.body + is TimelineItemEncryptedContent -> context.getString(R.string.common_unable_to_decrypt) + is TimelineItemRedactedContent -> context.getString(R.string.common_message_removed) + is TimelineItemUnknownContent -> context.getString(R.string.common_unsupported_event) + is TimelineItemImageContent -> context.getString(R.string.common_image) + is TimelineItemVideoContent -> context.getString(R.string.common_video) + is TimelineItemFileContent -> context.getString(R.string.common_file) + } + } +} 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 28765cc110..cb1c923397 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 @@ -29,13 +29,21 @@ 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.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter +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.TimelineItemVideoContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl import io.element.android.features.messages.media.FakeLocalMediaFactory +import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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 @@ -105,6 +113,105 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action reply to an event with no id does nothing`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) + skipItems(1) + // Otherwise we would have some extra items here + ensureAllEventsConsumed() + } + } + + @Test + fun `present - handle action reply to an image media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemImageContent( + body = "image.jpg", + mediaSource = MediaSource(AN_AVATAR_URL), + mimeType = MimeTypes.Jpeg, + blurhash = null, + width = 20, + height = 20, + aspectRatio = 1.0f, + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + } + } + + @Test + fun `present - handle action reply to a video media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemVideoContent( + body = "video.mp4", + duration = 10L, + videoSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + mimeType = MimeTypes.Mp4, + blurHash = null, + width = 20, + height = 20, + aspectRatio = 1.0f, + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + } + } + + @Test + fun `present - handle action reply to a file media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemFileContent( + body = "video.mp4", + fileSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + formattedFileSize = "10 MB", + mimeType = MimeTypes.Pdf, + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + } + } + @Test fun `present - handle action edit`() = runTest { val presenter = createMessagePresenter() @@ -197,6 +304,7 @@ class MessagesPresenterTest { actionListPresenter = actionListPresenter, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), + messageSummaryFormatter = FakeMessageSummaryFormatter(), ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index 169912d534..b1aa411148 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -245,5 +245,6 @@ private fun aMessageEvent( sentTime = "", isMine = isMine, reactionsState = TimelineItemReactions(persistentListOf()), - sendState = EventSendState.Sent(AN_EVENT_ID) + sendState = EventSendState.Sent(AN_EVENT_ID), + inReplyTo = null, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt index bd6be5b517..496c022983 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt @@ -21,7 +21,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_USER_ID @@ -29,11 +31,13 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import kotlinx.collections.immutable.persistentListOf internal fun aMessageEvent( + eventId: EventId? = AN_EVENT_ID, isMine: Boolean = true, content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), + inReplyTo: InReplyTo? = null, ) = TimelineItem.Event( - id = AN_EVENT_ID.value, - eventId = AN_EVENT_ID, + id = eventId?.value.orEmpty(), + eventId = eventId, senderId = A_USER_ID, senderDisplayName = A_USER_NAME, senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME), @@ -41,5 +45,6 @@ internal fun aMessageEvent( sentTime = "", isMine = isMine, reactionsState = TimelineItemReactions(persistentListOf()), - sendState = EventSendState.Sent(AN_EVENT_ID) + sendState = EventSendState.Sent(AN_EVENT_ID), + inReplyTo = inReplyTo, ) 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 bfef3b4011..8fc9b702bc 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 @@ -483,5 +483,5 @@ class MessageComposerPresenterTest { } fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) -fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, AN_EVENT_ID, A_MESSAGE) +fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt index 3edde16841..b1a17beb6c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -42,7 +42,8 @@ class TimelineItemGrouperTest { senderDisplayName = "", content = TimelineItemStateEventContent(body = "a state event"), reactionsState = TimelineItemReactions(emptyList().toImmutableList()), - sendState = EventSendState.Sent(AN_EVENT_ID) + sendState = EventSendState.Sent(AN_EVENT_ID), + inReplyTo = null, ) private val aNonGroupableItem = aMessageEvent() private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt new file mode 100644 index 0000000000..3f8205e555 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt @@ -0,0 +1,31 @@ +/* + * 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.utils.messagesummary + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter + +class FakeMessageSummaryFormatter : MessageSummaryFormatter { + + private var result = "A message" + + override fun format(event: TimelineItem.Event): String = result + + fun givenMessageResult(value: String) { + result = value + } +} diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 09361a5667..f0d937ead3 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -31,6 +31,7 @@ android { // Should not be there, but this is a POC implementation(libs.coil.compose) implementation(libs.accompanist.systemui) + implementation(libs.vanniktech.blurhash) implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt similarity index 97% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt index 9237e87e9d..0ddd0b7346 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/blurhash/BlurHashAsyncImage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.timeline.components.blurhash +package io.element.android.libraries.designsystem.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt new file mode 100644 index 0000000000..8804066da8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +/** + * Used to create a column where all children have the same width. + * It will first measure all children, get the largest width and re-measure all children with this width as the minWidth. + * + * *Note*: If all children already have the same width, it skips the 2nd measuring and acts like a normal Column. + */ +@Composable +fun EqualWidthColumn( + modifier: Modifier = Modifier, + spacing: Dp = 0.dp, + content: @Composable () -> Unit +) { + SubcomposeLayout(modifier = modifier) { constraints -> + val measurables = subcompose(0, content).map { it.measure(constraints) } + val maxWidth = measurables.maxOf { it.width } + val newConstraints = constraints.copy(minWidth = maxWidth) + val newMeasurables = if (measurables.all { it.width == maxWidth }) { + // Skip re-measuring if all children have the same width + measurables + } else { + // Re-measure with the largest width as the minWidth to have all children constrained to the same width + subcompose(1, content).map { it.measure(newConstraints) } + } + val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt() + layout(maxWidth, totalHeight) { + var yPosition = 0 + newMeasurables.forEach { measurable -> + measurable.placeRelative(0, yPosition) + yPosition += measurable.height + spacing.roundToPx() + } + } + } +} 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 dafaa95936..57e3993926 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 @@ -28,11 +28,25 @@ sealed interface EventContent data class MessageContent( val body: String, - val inReplyTo: EventId?, + val inReplyTo: InReplyTo?, val isEdited: Boolean, val type: MessageType? ) : EventContent + +sealed interface InReplyTo { + data class NotLoaded(val eventId: EventId) : InReplyTo + data class Ready( + val eventId: EventId, + val content: MessageContent, + val senderId: UserId, + val senderDisplayName: String?, + val senderAvatarUrl: String?, + ) : InReplyTo + + object Error : InReplyTo +} + object RedactedContent : EventContent data class StickerContent( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 8b667107d7..81aa2dc5c4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -32,4 +32,12 @@ data class EventTimelineItem( val senderProfile: ProfileTimelineDetails, val timestamp: Long, val content: EventContent -) +) { + fun inReplyTo(): InReplyTo? { + return (content as? MessageContent)?.inReplyTo + } + fun hasNotLoadedInReplyTo(): Boolean { + val details = inReplyTo() + return details is InReplyTo.NotLoaded || details is InReplyTo.Error + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt index 5b4eaa9e36..c90e672f28 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt @@ -16,12 +16,19 @@ package io.element.android.libraries.matrix.impl.timeline +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.TimelineItem +import timber.log.Timber class MatrixTimelineItemMapper( + private val room: Room, + private val coroutineScope: CoroutineScope, private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(), private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(), ) { @@ -30,6 +37,12 @@ class MatrixTimelineItemMapper( val asEvent = it.asEvent() if (asEvent != null) { val eventTimelineItem = eventTimelineItemMapper.map(asEvent) + + + if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) { + fetchDetailsForEvent(eventTimelineItem.eventId!!) + } + return MatrixTimelineItem.Event(eventTimelineItem) } val asVirtual = it.asVirtual() @@ -39,4 +52,13 @@ class MatrixTimelineItemMapper( } return MatrixTimelineItem.Other } + + private fun fetchDetailsForEvent(eventId: EventId) = coroutineScope.launch { + runCatching { + room.fetchDetailsForEvent(eventId.value) + }.onFailure { + Timber.e(it) + } + } + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index 923815a714..408861226b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -63,6 +63,8 @@ class RustMatrixTimeline( ) private val timelineItemFactory = MatrixTimelineItemMapper( + room = innerRoom, + coroutineScope = coroutineScope, virtualTimelineItemMapper = VirtualTimelineItemMapper(), eventTimelineItemMapper = EventTimelineItemMapper( contentMapper = TimelineEventContentMapper( 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 2e4693c1fb..8a052d1a3a 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 @@ -17,11 +17,13 @@ package io.element.android.libraries.matrix.impl.timeline.item.event import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType @@ -31,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.impl.media.map import org.matrix.rustcomponents.sdk.Message import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.RepliedToEventDetails import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat @@ -66,9 +70,26 @@ class EventMessageMapper { } } } + val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) + val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details -> + when (details) { + is RepliedToEventDetails.Ready -> { + val senderProfile = details.senderProfile as? ProfileDetails.Ready + InReplyTo.Ready( + eventId = inReplyToId!!, + content = map(details.message), + senderId = UserId(details.sender), + senderDisplayName = senderProfile?.displayName, + senderAvatarUrl = senderProfile?.avatarUrl, + ) + } + is RepliedToEventDetails.Error -> InReplyTo.Error + is RepliedToEventDetails.Pending, is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId!!) + } + } MessageContent( body = it.body(), - inReplyTo = it.inReplyTo()?.eventId?.let(::EventId), + inReplyTo = inReplyToEvent, isEdited = it.isEdited(), type = type ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt new file mode 100644 index 0000000000..5a3bad8988 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -0,0 +1,93 @@ +/* + * 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.ui.components + +import android.os.Parcelable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material.icons.outlined.VideoCameraBack +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.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import kotlinx.parcelize.Parcelize + +@Composable +fun AttachmentThumbnail( + info: AttachmentThumbnailInfo, + modifier: Modifier = Modifier, + thumbnailSize: Long = 32L, + backgroundColor: Color = MaterialTheme.colorScheme.surface, +) { + if (info.mediaSource != null) { + val mediaRequestData = MediaRequestData( + source = info.mediaSource, + kind = MediaRequestData.Kind.Thumbnail(thumbnailSize), + ) + BlurHashAsyncImage( + model = mediaRequestData, + blurHash = info.blurHash, + contentDescription = info.textContent, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + } else { + Box( + modifier = modifier.background(backgroundColor), + contentAlignment = Alignment.Center + ) { + when (info.type) { + AttachmentThumbnailType.Video -> { + Icon( + imageVector = Icons.Outlined.VideoCameraBack, + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.File -> { + Icon( + imageVector = Icons.Outlined.Attachment, + contentDescription = info.textContent, + modifier = Modifier.rotate(-45f) + ) + } + else -> Unit + } + } + } +} + +@Parcelize +enum class AttachmentThumbnailType: Parcelable { + Image, Video, File +} + +@Parcelize +data class AttachmentThumbnailInfo( + val mediaSource: MediaSource?, + val textContent: String?, + val type: AttachmentThumbnailType?, + val blurHash: String?, +): Parcelable diff --git a/libraries/textcomposer/build.gradle.kts b/libraries/textcomposer/build.gradle.kts index 6af1d8c598..dee2abc5c6 100644 --- a/libraries/textcomposer/build.gradle.kts +++ b/libraries/textcomposer/build.gradle.kts @@ -22,9 +22,6 @@ plugins { android { namespace = "io.element.android.libraries.textcomposer" - buildFeatures { - viewBinding = true - } } dependencies { @@ -33,9 +30,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(libs.wysiwyg) - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.material) ksp(libs.showkase.processor) } diff --git a/libraries/textcomposer/src/main/AndroidManifest.xml b/libraries/textcomposer/src/main/AndroidManifest.xml deleted file mode 100644 index 19db0c3d57..0000000000 --- a/libraries/textcomposer/src/main/AndroidManifest.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt index 093fb865fa..5539b781ea 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer import android.os.Parcelable import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import kotlinx.parcelize.Parcelize sealed interface MessageComposerMode : Parcelable { @@ -38,6 +39,7 @@ sealed interface MessageComposerMode : Parcelable { @Parcelize class Reply( val senderName: String, + val attachmentThumbnailInfo: AttachmentThumbnailInfo?, override val eventId: EventId, override val defaultContent: CharSequence ) : Special(eventId, defaultContent) diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 92bcff5000..ccf4030ce1 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -59,8 +59,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.ElementTextStyles @@ -73,6 +75,10 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class) @@ -180,21 +186,99 @@ private fun ComposerModeView( ) { when (composerMode) { is MessageComposerMode.Edit -> { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp)) { - Icon( - resourceId = VectorIcons.Edit, - contentDescription = stringResource(R.string.editing), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(16.dp), - ) + EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier) + } + is MessageComposerMode.Reply -> { + ReplyToModeView( + modifier = modifier.padding(8.dp), + senderName = composerMode.senderName, + text = composerMode.defaultContent.toString(), + attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, + onResetComposerMode = onResetComposerMode, + ) + } + else -> Unit + } +} + +@Composable +private fun EditingModeView( + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) { + Icon( + resourceId = VectorIcons.Edit, + contentDescription = stringResource(R.string.editing), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp), + ) + Text( + stringResource(R.string.editing), + style = ElementTextStyles.Regular.caption2, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(StringR.string.action_close), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false) + ), + + ) + } +} + +@Composable +private fun ReplyToModeView( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + val paddings = if (attachmentThumbnailInfo != null) { + PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) + } else { + PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp) + } + Row( + modifier + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Column(verticalArrangement = Arrangement.SpaceEvenly) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { Text( - stringResource(R.string.editing), - style = ElementTextStyles.Regular.caption2, + senderName, + style = ElementTextStyles.Regular.caption2.copy(fontWeight = FontWeight.Medium), textAlign = TextAlign.Start, - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f) ) Icon( @@ -209,11 +293,19 @@ private fun ComposerModeView( interactionSource = MutableInteractionSource(), indication = rememberRipple(bounded = false) ), - ) } + + Text( + modifier = Modifier.fillMaxWidth(), + text = text.orEmpty(), + style = ElementTextStyles.Regular.caption1, + textAlign = TextAlign.Start, + color = LocalColors.current.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - else -> Unit } } @@ -289,14 +381,30 @@ private fun BoxScope.SendButton( @Preview @Composable -internal fun TextComposerLightPreview() = ElementPreviewLight { ContentToPreview() } +internal fun TextComposerSimpleLightPreview() = ElementPreviewLight { SimpleContentToPreview() } @Preview @Composable -internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview() } +internal fun TextComposerSimpleDarkPreview() = ElementPreviewDark { SimpleContentToPreview() } + +@Preview +@Composable +internal fun TextComposerEditLightPreview() = ElementPreviewLight { EditContentToPreview() } + +@Preview +@Composable +internal fun TextComposerEditDarkPreview() = ElementPreviewDark { EditContentToPreview() } + +@Preview +@Composable +internal fun TextComposerReplyLightPreview() = ElementPreviewLight { ReplyContentToPreview() } + +@Preview +@Composable +internal fun TextComposerReplyDarkPreview() = ElementPreviewDark { ReplyContentToPreview() } @Composable -private fun ContentToPreview() { +private fun SimpleContentToPreview() { Column { TextComposer( onSendMessage = {}, @@ -322,10 +430,89 @@ private fun ContentToPreview() { composerCanSendMessage = true, composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", ) + } +} + +@Composable +private fun EditContentToPreview() { + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) +} + +@Composable +private fun ReplyContentToPreview() { + Column { TextComposer( onSendMessage = {}, onComposerTextChange = {}, - composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = null, + defaultContent = "A message\n" + + "With several lines\n" + + "To preview larger textfields and long lines with overflow" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = MediaSource("https://domain.com/image.jpg"), + textContent = "image.jpg", + type = AttachmentThumbnailType.Image, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "image.jpg" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = MediaSource("https://domain.com/video.mp4"), + textContent = "video.mp4", + type = AttachmentThumbnailType.Video, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "video.mp4" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + mediaSource = null, + textContent = "logs.txt", + type = AttachmentThumbnailType.File, + blurHash = null, + ), + defaultContent = "logs.txt" + ), onResetComposerMode = {}, composerCanSendMessage = true, composerText = "A message", diff --git a/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml b/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml deleted file mode 100644 index 647dc58213..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/bg_rich_text_menu_button.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml b/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml deleted file mode 100644 index 3d66d9db2e..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/bottomsheet_handle.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml deleted file mode 100644 index 4a3051618d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_bold.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml deleted file mode 100644 index 625399e53a..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_collapse.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml deleted file mode 100644 index d8d5f8ef4d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_full_screen.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml deleted file mode 100644 index 11a63eb7bb..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_italic.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml deleted file mode 100644 index b67fad6749..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_close.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml deleted file mode 100644 index 009cfc303f..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml deleted file mode 100644 index 7591806ba6..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_rich_text_save.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml deleted file mode 100644 index 937c3a08a4..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_strikethrough.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml b/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml deleted file mode 100644 index ac8ff7a96d..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_composer_underlined.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_quote.xml b/libraries/textcomposer/src/main/res/drawable/ic_quote.xml deleted file mode 100644 index 706bf88faa..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_quote.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml b/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml deleted file mode 100644 index 6f812c8b44..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_add.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml b/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml deleted file mode 100644 index 3373db1399..0000000000 --- a/libraries/textcomposer/src/main/res/drawable/ic_rich_composer_send.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml b/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml deleted file mode 100644 index 2e8eb80855..0000000000 --- a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml b/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml deleted file mode 100644 index fa264045e7..0000000000 --- a/libraries/textcomposer/src/main/res/layout/view_rich_text_menu_button.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 72f00d3be0..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7441f73e8567bbac360867f9b860621ec4766a67d5295d04cda45a09f942d0b5 -size 47865 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e85b7a81af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1207e4830152c3387f9464fa4f10bd92f605d14b37f1c556d8e610060246ac2e +size 14094 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8e266f9795 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:127ec47cd443c96270f07d6d3ca132a184c6ad99ab8ade04dc3d60f5a6f555da +size 13575 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 4cb8b441f3..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f44fe7919578afe43e77b414f294e2ae7f1761c9528feeab548303428bfeba43 -size 46117 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c8904142b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8922908539dc4978a23f72342e46a4c259322559d1f0dbe978dcf3ae8a4e79bf +size 66820 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4c0408f4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a5b67e808ef8d171167e7fb241ba76b7e6d5ae48d193f6384bf3e2c4a0743f3 +size 66423 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1c8d0ea0a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97fb04ff64617a6ba8c683a39921864bded4b05269c958aa4c91fda0bc21963a +size 39298 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6896fab3c2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimpleLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd9f6eaf4bfc4b226f1fde5bdaf0262b95e3f9ac2e7e0d90648d72ef0add11d2 +size 37492 From b6a38219910655d5cd63184a79cafb8cc1a2d552 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 8 Jun 2023 15:11:44 +0200 Subject: [PATCH 32/32] Disable kotlin incremental compilation for now, waiting for better --- gradle.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gradle.properties b/gradle.properties index ae25b1ed02..e15ee7a033 100644 --- a/gradle.properties +++ b/gradle.properties @@ -38,6 +38,9 @@ android.nonTransitiveRClass=true org.gradle.caching=true org.gradle.configureondemand=true org.gradle.parallel=true +# Check here for the reasons https://github.com/square/anvil/issues/693 +# useClasspathSnapshot=false is not enough in most cases. +kotlin.incremental=false # Dummy values for signing secrets / nightly signing.element.nightly.storePassword=Secret