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 @@ + + + { 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/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/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..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,21 +20,27 @@ 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 -import io.element.android.libraries.core.mimetype.MimeTypes open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence 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(), MimeTypes.Jpeg, "an image", 1000L), + 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 a96cd43628..8eca2c3313 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 @@ -121,7 +121,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/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..fcf64eb24f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.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.helper + +import android.webkit.MimeTypeMap + +fun formatFileExtensionAndSize(name: String, size: String?): String { + 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(safeExtension) + 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 new file mode 100644 index 0000000000..44bff4f5ab --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -0,0 +1,161 @@ +/* + * 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.content.Intent +import android.net.Uri +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 +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 AndroidLocalMediaActions @Inject constructor( + @ApplicationContext private val context: Context, + private val coroutineDispatchers: CoroutineDispatchers, + 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 { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveOnDiskUsingMediaStore(localMedia) + } 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 = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatching { + 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) { + val intent = Intent.createChooser(shareMediaIntent, null) + activityContext!!.startActivity(intent) + } + }.onSuccess { + Timber.v("Share media succeed") + }.onFailure { + Timber.e(it, "Share media failed") + } + } + + 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) + } + }.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.info.name) + put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + val resolver = context.contentResolver + 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) + } + } + } + } + + private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) { + val target = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + localMedia.info.name + ) + localMedia.openStream()?.use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + } + } + } + + private fun LocalMedia.openStream(): InputStream? { + return context.contentResolver.openInputStream(uri) + } + + /** + * Tries to extract a file from the 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/AndroidLocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt index bc2f1a066c..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 @@ -20,33 +20,50 @@ 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 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) class AndroidLocalMediaFactory @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val fileSizeFormatter: FileSizeFormatter, ) : LocalMediaFactory { - override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia { - val uri = mediaFile.path().toUri() - return createFromUri(uri, mimeType) + 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?): LocalMedia { - val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri) ?: MimeTypes.OctetStream - val fileName = context.getFileName(uri) - val fileSize = context.getFileSize(uri) + 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 = 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 8305c8eee7..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 @@ -19,22 +19,11 @@ package io.element.android.features.messages.impl.media.local import android.net.Uri import android.os.Parcelable import androidx.compose.runtime.Immutable -import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @Immutable data class LocalMedia( val uri: Uri, - val mimeType: String, - val name: String?, - val size: Long, -) : Parcelable { - - /** - * This tries to convert the uri to a file if applicable, otherwise keep it as uri. - */ - @IgnoredOnParcel val model: Any by lazy { - UriToFileMapper.map(uri) ?: uri - } -} + 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 new file mode 100644 index 0000000000..f35af36057 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -0,0 +1,44 @@ +/* + * 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 + +interface LocalMediaActions { + + @Composable + fun Configure() + + /** + * 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 + + /** + * Will try to find a suitable application to open the media with. + * The [LocalMedia.uri] needs to have a file scheme. + */ + suspend fun open(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 09c44f4fba..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,13 +22,21 @@ import io.element.android.libraries.matrix.api.media.MediaFile interface LocalMediaFactory { /** - * This method will create a [LocalMedia] with the given [MediaFile] and [mimeType]. + * This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo]. */ - fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia + fun createFromMediaFile( + mediaFile: MediaFile, + mediaInfo: MediaInfo, + ): 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. + * 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?): LocalMedia + fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt index 142fb57102..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 @@ -17,18 +17,37 @@ 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.padding +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.TextAlign +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 +55,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 +63,9 @@ 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 import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomableState @@ -55,39 +78,45 @@ import me.saket.telephoto.zoomable.rememberZoomableState fun LocalMediaView( localMedia: LocalMedia?, modifier: Modifier = Modifier, - mimeType: String? = localMedia?.mimeType, - onReady: () -> Unit = {}, + localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), + mediaInfo: MediaInfo? = localMedia?.info, ) { val zoomableState = rememberZoomableState( zoomSpec = ZoomSpec(maxZoomFactor = 5f) ) + 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 -> Unit + else -> MediaFileView( + localMediaViewState = localMediaViewState, + uri = localMedia?.uri, + info = mediaInfo, + modifier = modifier + ) } } @Composable private fun MediaImageView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, zoomableState: ZoomableState, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { if (LocalInspectionMode.current) { @@ -98,15 +127,11 @@ 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, - model = localMedia?.model, + model = localMedia?.uri, contentDescription = "Image", contentScale = ContentScale.Fit, ) @@ -116,14 +141,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 { @@ -170,19 +195,64 @@ fun MediaVideoView( @Composable fun MediaPDFView( + localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, zoomableState: ZoomableState, - onReady: () -> Unit, modifier: Modifier = Modifier, ) { val pdfViewerState = rememberPdfViewerState( - model = localMedia?.model, + 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?, + modifier: Modifier = Modifier, +) { + localMediaViewState.isReady = uri != null + Box(modifier = modifier.padding(horizontal = 8.dp), 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 = null, + tint = MaterialTheme.colorScheme.background, + modifier = Modifier + .size(32.dp) + .rotate(-45f), + ) + } + if (info != null) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = info.name, + maxLines = 2, + fontSize = 16.sp, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = ElementTheme.colors.gray1400 + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.name, info.formattedFileSize), + 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/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() + } +} 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..57cd788bb8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt @@ -0,0 +1,44 @@ +/* + * 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.jpg", MimeTypes.Jpeg, "4MB" +) + +fun aVideoInfo(): MediaInfo = MediaInfo( + "a video file.mp4", MimeTypes.Mp4, "14MB" +) + +fun aPdfInfo(): MediaInfo = MediaInfo( + "a pdf file.pdf", MimeTypes.Pdf, "23MB" +) + +fun aFileInfo(): MediaInfo = MediaInfo( + "an apk file.apk", MimeTypes.Apk, "50MB" +) 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/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) + } + } } 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..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 @@ -17,6 +17,9 @@ 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 247a86263f..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() @@ -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..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 @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.media.viewer +import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -28,18 +29,26 @@ 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.LocalMediaActions import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.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.androidutils.R as UtilsR +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 localMediaActions: LocalMediaActions, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @AssistedFactory @@ -57,6 +66,8 @@ class MediaViewerPresenter @AssistedInject constructor( val localMedia: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + localMediaActions.Configure() DisposableEffect(loadMediaTrigger) { coroutineScope.downloadMedia(mediaFile, localMedia) onDispose { @@ -68,29 +79,84 @@ class MediaViewerPresenter @AssistedInject constructor( when (mediaViewerEvents) { MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized + MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) + MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) + MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value) } } return MediaViewerState( - name = inputs.name, - mimeType = inputs.mimeType, + mediaInfo = inputs.mediaInfo, thumbnailSource = inputs.thumbnailSource, downloadedMedia = localMedia.value, + snackbarMessage = snackbarMessage, eventSink = ::handleEvents ) } private fun CoroutineScope.downloadMedia(mediaFile: MutableState, localMedia: MutableState>) = launch { localMedia.value = Async.Loading() - mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType) + mediaLoader.downloadMediaFile( + source = inputs.mediaSource, + mimeType = inputs.mediaInfo.mimeType, + body = inputs.mediaInfo.name + ) .onSuccess { mediaFile.value = it - }.mapCatching { - localMediaFactory.createFromMediaFile(it, inputs.mimeType) + }.mapCatching { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mediaInfo = inputs.mediaInfo + ) }.onSuccess { localMedia.value = Async.Success(it) }.onFailure { localMedia.value = Async.Failure(it) } } + + private fun CoroutineScope.saveOnDisk(localMedia: Async) = launch { + 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 { + 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 { + 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 { + 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 c42263dd3e..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,15 @@ 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?, 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..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 @@ -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,41 @@ 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.Loading(), + aFileInfo(), + ), + 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 ae598688d1..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 @@ -14,6 +14,7 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) package io.element.android.features.messages.impl.media.viewer @@ -21,8 +22,21 @@ 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 import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,18 +46,25 @@ 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 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.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData import kotlinx.coroutines.delay @@ -52,6 +73,7 @@ import io.element.android.libraries.ui.strings.R as StringR @Composable fun MediaViewerView( state: MediaViewerState, + onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { @@ -63,61 +85,132 @@ fun MediaViewerView( state.eventSink(MediaViewerEvents.ClearLoadingError) } - var showProgress by remember { - mutableStateOf(false) - } + val localMediaViewState = rememberLocalMediaViewState() + val showThumbnail = !localMediaViewState.isReady + val showProgress = rememberShowProgress(state.downloadedMedia) + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) - // Trick to avoid showing progress indicator if the media is already on disk. - // When sdk will expose download progress we'll be able to remove this. - LaunchedEffect(state.downloadedMedia) { - showProgress = false - delay(100) - if (state.downloadedMedia.isLoading()) { - showProgress = true - } - } - - var showThumbnail by remember { - mutableStateOf(true) - } - - fun onMediaReady() { - showThumbnail = false - } - - Scaffold(modifier) { - Box( + Scaffold( + modifier, + topBar = { + MediaViewerTopBar( + actionsEnabled = state.downloadedMedia is Async.Success, + onBackPressed = onBackPressed, + eventSink = state.eventSink + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary + ) + } + }, + ) { + 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( + localMediaViewState = localMediaViewState, + localMedia = state.downloadedMedia.dataOrNull(), + mediaInfo = state.mediaInfo, + ) + 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 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, + onBackPressed: () -> Unit, + eventSink: (MediaViewerEvents) -> Unit, +) { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.OpenWith) + }, + ) { + Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = StringR.string.action_open_with)) + } + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.SaveOnDisk) + }, + ) { + Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = StringR.string.action_save)) + } + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.Share) + }, + ) { + Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = StringR.string.action_share)) + } + } + ) +} + @Composable private fun ThumbnailView( thumbnailSource: MediaSource?, showThumbnail: Boolean, - showProgress: Boolean, ) { AnimatedVisibility( visible = showThumbnail, @@ -139,14 +232,6 @@ private fun ThumbnailView( contentScale = ContentScale.Fit, contentDescription = null, ) - if (showProgress) { - Box( - modifier = Modifier.roundedBackground(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } } } } @@ -175,5 +260,6 @@ fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class private fun ContentToPreview(state: MediaViewerState) { MediaViewerView( state = state, + onBackPressed = {} ) } 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..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) + 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/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index 36aaa27be3..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 @@ -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,9 @@ fun TimelineItemFileView( Text( text = content.fileExtensionAndSize, color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } 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/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 361e80a4de..d94f32a88f 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -9,5 +9,6 @@ "Record a video" "Attachment" "Photo & Video Library" + "Could not retrieve user details" "Failed processing media to upload, please try again." \ No newline at end of file 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 60a01a76d4..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,21 +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.mockk.mockk +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, - mimeType = mimeType, - name = name, - size = size, + 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/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt new file mode 100644 index 0000000000..25a62e439a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.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(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions { + + var shouldFail = false + + @Composable + override fun Configure() { + //NOOP + } + + 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 = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + 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/FakeLocalMediaFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt index 89f4e96173..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,18 +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, mimeType: String?): LocalMedia { - return aLocalMedia(uri = localMediaUri, mimeType = mimeType ?: fallbackMimeType) + override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia { + return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo) } - override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia { - return aLocalMedia(uri, mimeType ?: fallbackMimeType) + override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia { + 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 1ef8097d4a..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,65 +19,122 @@ 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 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.matrix.test.FAKE_DELAY_IN_MS +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher 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 mockMediaUrl: Uri = mockk("localMediaUri") - private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) - private val mediaLoader = FakeMediaLoader() + private val mockMediaUri: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) @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.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.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 @@ -86,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) @@ -94,16 +150,20 @@ class MediaViewerPresenterTest { } } - private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { + private fun aMediaViewerPresenter( + mediaLoader: FakeMediaLoader, + localMediaActions: FakeLocalMediaActions, + ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerNode.Inputs( - name = TESTED_MEDIA_NAME, + mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), - mimeType = mimeType, thumbnailSource = null ), localMediaFactory = localMediaFactory, - mediaLoader = mediaLoader + mediaLoader = mediaLoader, + localMediaActions = localMediaActions, + snackbarDispatcher = SnackbarDispatcher() ) } } 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 d8c22b9f66..b2f5e0f933 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 @@ -41,17 +41,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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -85,8 +81,8 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.noFontPadding 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 @@ -184,21 +180,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/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 } 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, 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 71% 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..35b81ff324 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,11 +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 kotlinx.coroutines.Dispatchers +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -56,7 +59,7 @@ fun handleSnackbarMessage( val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) LaunchedEffect(snackbarMessage) { if (snackbarMessage != null) { - launch(Dispatchers.Main) { + launch { snackbarDispatcher.clear() } } @@ -64,6 +67,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, 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..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,8 +33,9 @@ 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?): Result + suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt 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()) +} 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 9e4f2c53de..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) { @@ -59,14 +67,16 @@ 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, - mimeType = mimeType ?: "application/octet-stream" + body = body, + mimeType = mimeType ?: "application/octet-stream", + //TODO uncomment when rust api will be merged + //tempDir = cacheDirectory.path, ) RustMediaFile(mediaFile) } 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 96c49aa165..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?): 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/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index d38bf7d8dd..922d35b3e1 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -7,6 +7,7 @@ "** Failed to send - please open room" "Join" "Reject" + "invited you" "New Messages" "Mark as read" "Me" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index f3cada2464..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" @@ -145,7 +146,20 @@ "This is the beginning of %1$s." "This is the beginning of this conversation." "New" + "Change account provider" + "Continue" + "Homeserver address" + "Enter a search term or a domain address." + "Search for a company, community, or private server." + "Find an account provider" + "You’re about to sign in to %s" + "This is where you conversations will live — just like you would use an email provider to keep your emails." + "You’re about to create an account on %s" "Share analytics data" + "Matrix.org is an open network for secure, decentralized communication." + "Other" + "Use a different account provider, such as your own private server or a work account." + "Change account provider" "Failed selecting media, please try again." "Failed processing media to upload, please try again." "Failed uploading media, please try again." @@ -168,4 +182,4 @@ "You can read all our terms %1$s." "here" "Block user" - + \ No newline at end of file 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) } 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 27ad024712..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:846f4846bed07b1aa030c07fbde42bdf38f6329250e1641ad83a9bd8786ea683 -size 183396 +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..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:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607 -size 98742 +oid sha256:846f4846bed07b1aa030c07fbde42bdf38f6329250e1641ad83a9bd8786ea683 +size 183396 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_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..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:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e -size 393618 +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_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..289d08f536 --- /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: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 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