From bc35db3ffd06842f7f1ee98be215061bcaf88773 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 1 Jun 2023 22:01:05 +0200 Subject: [PATCH 01/25] 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 7b90f5bfcf5bb1e2b71f4e027badc935d70784c6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 1 Jun 2023 22:35:17 +0200 Subject: [PATCH 02/25] 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 966199a007f33e2e6146a389e3f3f06144353fa4 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 08:49:28 +0200 Subject: [PATCH 03/25] 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 738693621754d0efe01c7607810be801f6b2cfe8 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 09:44:07 +0200 Subject: [PATCH 04/25] 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 1c01c0a6ccaffe119603ea46fc706d36b892ae8f Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 16:43:28 +0200 Subject: [PATCH 05/25] 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 d5bff3437b6b01623d9590d232fed82c30eaaf5c Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 17:29:24 +0200 Subject: [PATCH 06/25] 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 1d9ef72ed50ec9c539d16866008ba3ed4cb8e47d Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 17:46:48 +0200 Subject: [PATCH 07/25] 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 190da5f7389800f8658417d21d35d1d952627678 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 19:22:23 +0200 Subject: [PATCH 08/25] 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 89d4b81f8072f5c42a61ede252d1fd25ab0b64a9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 19:22:47 +0200 Subject: [PATCH 09/25] 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 fa63ed1faffe40afa00be7c7a77611ea22ff3508 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Jun 2023 20:13:17 +0200 Subject: [PATCH 10/25] 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 e322ba1b3205cbd1fe426ebca80693664b76fd5f Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Jun 2023 20:52:17 +0200 Subject: [PATCH 11/25] 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 be009baed7a843959410a3448703f0b48d6abf6e Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Jun 2023 22:45:49 +0200 Subject: [PATCH 12/25] 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 092e1544ca0457e0d0d541bf81049e2806bc6c34 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Jun 2023 23:39:48 +0200 Subject: [PATCH 13/25] 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 c16e4c46bda2d08a0df1c5075987c6eec6cc5673 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 13:44:49 +0200 Subject: [PATCH 14/25] 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 950e474c722566438f402f644cbc56eaf12219f7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 13:48:12 +0200 Subject: [PATCH 15/25] 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 c0def1c3dcd1a4b5077748224be2af0105321a61 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 22:08:57 +0200 Subject: [PATCH 16/25] 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 468ed5276fc71b7141f724bfade194bb1de0c383 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 22:18:13 +0200 Subject: [PATCH 17/25] 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 51389053bfe54311a62eb09f8a129d5397e95bbb Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 22:55:15 +0200 Subject: [PATCH 18/25] 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 f8db17c67027d29737affad1c00568b5301f7225 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 6 Jun 2023 21:21:14 +0000 Subject: [PATCH 19/25] 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 0c533986bc06b060b67f90f695f6c40fecbbeebd Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 6 Jun 2023 23:43:02 +0200 Subject: [PATCH 20/25] 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 a09ea589f20abd67bcf9d82a84da052efe5e4504 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Jun 2023 15:41:07 +0200 Subject: [PATCH 21/25] 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 c9f6093f22c79e4b879fce10b82fbd7703aaebdb Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Jun 2023 15:55:16 +0200 Subject: [PATCH 22/25] 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 c48dd99bf924723cf2d0fa362f094203c0dbc3a5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Jun 2023 15:58:05 +0200 Subject: [PATCH 23/25] 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 2f4ce0a8345ba2ca563e8797a77e9b4a09133635 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 7 Jun 2023 14:10:32 +0000 Subject: [PATCH 24/25] 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 6056b6b1e3ffa5e5173aa79d10eb32cef31acad0 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 7 Jun 2023 15:11:56 +0000 Subject: [PATCH 25/25] 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