Media: handle openWith and share actions (need to inject context for tests...). Also introduce MediaInfo

This commit is contained in:
ganfra 2023-06-05 20:52:17 +02:00
parent 8e1ade897d
commit 3a2ef0238b
25 changed files with 395 additions and 130 deletions

View file

@ -32,6 +32,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -64,10 +65,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class MediaViewer( data class MediaViewer(
val title: String, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val mimeType: String?,
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -100,10 +100,9 @@ class MessagesFlowNode @AssistedInject constructor(
} }
is NavTarget.MediaViewer -> { is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs( val inputs = MediaViewerNode.Inputs(
name = navTarget.title, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
mimeType = navTarget.mimeType,
) )
createNode<MediaViewerNode>(buildContext, listOf(inputs)) createNode<MediaViewerNode>(buildContext, listOf(inputs))
} }
@ -118,30 +117,39 @@ class MessagesFlowNode @AssistedInject constructor(
when (event.content) { when (event.content) {
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
val navTarget = NavTarget.MediaViewer( 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, mediaSource = event.content.mediaSource,
thumbnailSource = event.content.mediaSource, thumbnailSource = event.content.mediaSource,
mimeType = event.content.mimeType
) )
backstack.push(navTarget) backstack.push(navTarget)
} }
is TimelineItemVideoContent -> { is TimelineItemVideoContent -> {
val mediaSource = event.content.videoSource val mediaSource = event.content.videoSource
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
title = event.content.body, mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize
),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
mimeType = event.content.mimeType,
) )
backstack.push(navTarget) backstack.push(navTarget)
} }
is TimelineItemFileContent -> { is TimelineItemFileContent -> {
val mediaSource = event.content.fileSource val mediaSource = event.content.fileSource
val navTarget = NavTarget.MediaViewer( val navTarget = NavTarget.MediaViewer(
title = event.content.body, mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize
),
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = event.content.thumbnailSource, thumbnailSource = event.content.thumbnailSource,
mimeType = event.content.mimeType,
) )
backstack.push(navTarget) backstack.push(navTarget)
} }

View file

@ -84,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState: MutableState<Async<Unit>>, sendActionState: MutableState<Async<Unit>>,
) { ) {
suspend { suspend {
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible)
}.executeResult(sendActionState) }.executeResult(sendActionState)
} }
} }

View file

@ -20,8 +20,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment 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.LocalMedia
import io.element.android.features.messages.impl.media.local.anImageInfo
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.mimetype.MimeTypes
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> { open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState> override val values: Sequence<AttachmentsPreviewState>
@ -34,7 +34,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState( fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L), localMedia = LocalMedia("path".toUri(), anImageInfo()),
compressIfPossible = true compressIfPossible = true
), ),
sendActionState = sendActionState, sendActionState = sendActionState,

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.helper
fun formatFileExtensionAndSize(name: String, size: String?): String {
val fileExtension = name.substringAfterLast('.', "").uppercase()
return buildString {
append(fileExtension)
if (size != null) {
append(' ')
append("($size)")
}
}
}

View file

@ -20,6 +20,7 @@ import android.content.ContentResolver
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
@ -60,16 +61,17 @@ class AndroidLocalMediaActions @Inject constructor(
} }
} }
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun share(activityContext: Context, localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatching { runCatching {
val authority = "${buildMeta.applicationId}.fileprovider" val shareableUri = localMedia.toShareableUri()
val uriFromFileProvider = FileProvider.getUriForFile(context, authority, localMedia.toFile()) val shareMediaIntent = Intent(Intent.ACTION_SEND)
val shareMediaIntent = Intent(Intent.ACTION_VIEW) .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) .putExtra(Intent.EXTRA_STREAM, shareableUri)
.setDataAndType(uriFromFileProvider, localMedia.mimeType) .setTypeAndNormalize(localMedia.info.mimeType)
withContext(coroutineDispatchers.main) { withContext(coroutineDispatchers.main) {
context.startActivity(shareMediaIntent, null) val intent = Intent.createChooser(shareMediaIntent, null)
activityContext.startActivity(intent)
} }
}.onSuccess { }.onSuccess {
Timber.v("Share media succeed") Timber.v("Share media succeed")
@ -78,11 +80,33 @@ class AndroidLocalMediaActions @Inject constructor(
} }
} }
override suspend fun open(activityContext: Context, localMedia: LocalMedia): Result<Unit> = 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) @RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) { private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
val contentValues = ContentValues().apply { val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.name) put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.name)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.mimeType) put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
} }
val resolver = context.contentResolver val resolver = context.contentResolver
@ -99,7 +123,7 @@ class AndroidLocalMediaActions @Inject constructor(
private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) { private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) {
val target = File( val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
localMedia.name ?: "" localMedia.info.name
) )
localMedia.openStream()?.use { input -> localMedia.openStream()?.use { input ->
FileOutputStream(target).use { output -> FileOutputStream(target).use { output ->

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.media.local
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.squareup.anvil.annotations.ContributesBinding 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.getFileName
import io.element.android.libraries.androidutils.file.getFileSize import io.element.android.libraries.androidutils.file.getFileSize
import io.element.android.libraries.androidutils.file.getMimeType import io.element.android.libraries.androidutils.file.getMimeType
@ -29,18 +30,26 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class AndroidLocalMediaFactory @Inject constructor( class AndroidLocalMediaFactory @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context,
private val fileSizeFormatter: FileSizeFormatter,
) : LocalMediaFactory { ) : LocalMediaFactory {
override fun createFromUri(uri: Uri, mimeType: String?, name: String?): LocalMedia { override fun createFromUri(
uri: Uri,
mimeType: String?,
name: String?,
formattedFileSize: String?
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) val fileName = name ?: context.getFileName(uri) ?: ""
val fileSize = context.getFileSize(uri) val fileSize = formattedFileSize ?: fileSizeFormatter.format(context.getFileSize(uri))
return LocalMedia( return LocalMedia(
uri = uri, uri = uri,
mimeType = resolvedMimeType, info = MediaInfo(
name = fileName, mimeType = resolvedMimeType,
size = fileSize name = fileName,
formattedFileSize = fileSize
)
) )
} }
} }

View file

@ -19,14 +19,13 @@ package io.element.android.features.messages.impl.media.local
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@Immutable @Immutable
data class LocalMedia( data class LocalMedia(
val uri: Uri, val uri: Uri,
val mimeType: String, val info: MediaInfo,
val name: String?, ) : Parcelable {
val size: Long,
) : Parcelable }

View file

@ -16,6 +16,8 @@
package io.element.android.features.messages.impl.media.local package io.element.android.features.messages.impl.media.local
import android.content.Context
interface LocalMediaActions { interface LocalMediaActions {
/** /**
* Will save the current media to the Downloads directory. * Will save the current media to the Downloads directory.
@ -27,6 +29,12 @@ interface LocalMediaActions {
* Will try to find a suitable application to share the media with. * Will try to find a suitable application to share the media with.
* The [LocalMedia.uri] needs to have a file scheme. * The [LocalMedia.uri] needs to have a file scheme.
*/ */
suspend fun share(localMedia: LocalMedia): Result<Unit> suspend fun share(activityContext: Context, localMedia: LocalMedia): Result<Unit>
/**
* Will try to find a suitable application to open the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun open(activityContext: Context, localMedia: LocalMedia): Result<Unit>
} }

View file

@ -24,22 +24,21 @@ import io.element.android.libraries.matrix.api.media.toFile
interface LocalMediaFactory { interface LocalMediaFactory {
/** /**
* This method will create a [LocalMedia] with the given [uri] and [mimeType] * This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize
* If the [mimeType] is null, it'll try to read it from the content. * If any of those params are null, it'll try to read them from the content.
* If the [name] is null, it'll try to read it from the content.
*/ */
fun createFromUri( fun createFromUri(
uri: Uri, uri: Uri,
mimeType: String?, mimeType: String?,
name: String?, name: String?,
formattedFileSize: String?
): LocalMedia ): LocalMedia
} }
fun LocalMediaFactory.createFromMediaFile( fun LocalMediaFactory.createFromMediaFile(
mediaFile: MediaFile, mediaFile: MediaFile,
mimeType: String?, mediaInfo: MediaInfo,
name: String?
): LocalMedia { ): LocalMedia {
val uri = mediaFile.toFile().toUri() val uri = mediaFile.toFile().toUri()
return createFromUri(uri = uri, mimeType = mimeType, name = name) return createFromUri(uri = uri, mimeType = mediaInfo.mimeType, name = mediaInfo.name, formattedFileSize = mediaInfo.formattedFileSize)
} }

View file

@ -17,18 +17,35 @@
package io.element.android.features.messages.impl.media.local package io.element.android.features.messages.impl.media.local
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.compose.foundation.Image 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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@ -36,6 +53,7 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView 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.exoplayer.ExoPlayerWrapper
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
@ -43,6 +61,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableState import me.saket.telephoto.zoomable.ZoomableState
@ -55,12 +75,13 @@ import me.saket.telephoto.zoomable.rememberZoomableState
fun LocalMediaView( fun LocalMediaView(
localMedia: LocalMedia?, localMedia: LocalMedia?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
mimeType: String? = localMedia?.mimeType, info: MediaInfo? = localMedia?.info,
onReady: () -> Unit = {}, onReady: () -> Unit = {},
) { ) {
val zoomableState = rememberZoomableState( val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 5f) zoomSpec = ZoomSpec(maxZoomFactor = 5f)
) )
val mimeType = info?.mimeType
when { when {
mimeType.isMimeTypeImage() -> MediaImageView( mimeType.isMimeTypeImage() -> MediaImageView(
localMedia = localMedia, localMedia = localMedia,
@ -79,7 +100,12 @@ fun LocalMediaView(
onReady = onReady, onReady = onReady,
modifier = modifier modifier = modifier
) )
else -> Unit else -> MediaFileView(
uri = localMedia?.uri,
info = info,
onReady = onReady,
modifier = modifier
)
} }
} }
@ -186,3 +212,50 @@ fun MediaPDFView(
} }
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
} }
@Composable
fun MediaFileView(
uri: Uri?,
info: MediaInfo?,
onReady: () -> Unit,
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) {
if(uri != null) {
onReady()
}
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Attachment,
contentDescription = "OpenFile",
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(-45f),
)
}
if(info == null) return
Spacer(modifier = Modifier.height(16.dp))
Text(
text = info.name,
maxLines = 2,
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.name, info.formattedFileSize),
fontSize = 14.sp,
)
}
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.media.local
import android.os.Parcelable
import io.element.android.libraries.core.mimetype.MimeTypes
import kotlinx.parcelize.Parcelize
@Parcelize
data class MediaInfo(
val name: String,
val mimeType: String,
val formattedFileSize: String,
) : Parcelable
fun anImageInfo(): MediaInfo = MediaInfo(
"an image file", MimeTypes.Jpeg, "4MB"
)
fun aVideoInfo(): MediaInfo = MediaInfo(
"a video file", MimeTypes.Mp4, "14MB"
)
fun aPdfInfo(): MediaInfo = MediaInfo(
"a pdf file", MimeTypes.Pdf, "23MB"
)
fun aFileInfo(): MediaInfo = MediaInfo(
"an apk file", MimeTypes.Apk, "50MB"
)

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.media.viewer
sealed interface MediaViewerEvents { sealed interface MediaViewerEvents {
object SaveOnDisk: MediaViewerEvents object SaveOnDisk: MediaViewerEvents
object Share: MediaViewerEvents object Share: MediaViewerEvents
object OpenWith: MediaViewerEvents
object RetryLoading : MediaViewerEvents object RetryLoading : MediaViewerEvents
object ClearLoadingError : MediaViewerEvents object ClearLoadingError : MediaViewerEvents
} }

View file

@ -24,6 +24,7 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme import io.element.android.libraries.designsystem.theme.ForcedDarkElementTheme
@ -38,10 +39,9 @@ class MediaViewerNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val name: String, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val mimeType: String?
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()

View file

@ -16,6 +16,8 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
import android.content.ActivityNotFoundException
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@ -24,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -40,6 +43,7 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaFile
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
class MediaViewerPresenter @AssistedInject constructor( class MediaViewerPresenter @AssistedInject constructor(
@ -65,6 +69,7 @@ class MediaViewerPresenter @AssistedInject constructor(
val localMedia: MutableState<Async<LocalMedia>> = remember { val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized) mutableStateOf(Async.Uninitialized)
} }
val context = LocalContext.current
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
DisposableEffect(loadMediaTrigger) { DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia) coroutineScope.downloadMedia(mediaFile, localMedia)
@ -78,13 +83,13 @@ class MediaViewerPresenter @AssistedInject constructor(
MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) MediaViewerEvents.Share -> coroutineScope.share(context, localMedia.value)
MediaViewerEvents.OpenWith -> coroutineScope.open(context, localMedia.value)
} }
} }
return MediaViewerState( return MediaViewerState(
name = inputs.name, mediaInfo = inputs.mediaInfo,
mimeType = inputs.mimeType,
thumbnailSource = inputs.thumbnailSource, thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value, downloadedMedia = localMedia.value,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
@ -96,16 +101,15 @@ class MediaViewerPresenter @AssistedInject constructor(
localMedia.value = Async.Loading() localMedia.value = Async.Loading()
mediaLoader.downloadMediaFile( mediaLoader.downloadMediaFile(
source = inputs.mediaSource, source = inputs.mediaSource,
mimeType = inputs.mimeType, mimeType = inputs.mediaInfo.mimeType,
body = inputs.name body = inputs.mediaInfo.name
) )
.onSuccess { .onSuccess {
mediaFile.value = it mediaFile.value = it
}.mapCatching { mediaFile -> }.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile( localMediaFactory.createFromMediaFile(
mediaFile = mediaFile, mediaFile = mediaFile,
mimeType = inputs.mimeType, mediaInfo = inputs.mediaInfo
name = inputs.name
) )
}.onSuccess { }.onSuccess {
localMedia.value = Async.Success(it) localMedia.value = Async.Success(it)
@ -127,12 +131,39 @@ class MediaViewerPresenter @AssistedInject constructor(
} }
} }
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.share(activityContext: Context, localMedia: Async<LocalMedia>) = launch {
when (localMedia) { when (localMedia) {
is Async.Success -> mediaActionsHandler.share(localMedia.state) is Async.Success -> {
mediaActionsHandler.share(activityContext, localMedia.state)
.onFailure {
val snackbarMessage = SnackbarMessage(openShareError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
else -> Unit else -> Unit
} }
} }
private fun CoroutineScope.open(activityContext: Context, localMedia: Async<LocalMedia>) = launch {
when (localMedia) {
is Async.Success -> {
mediaActionsHandler.open(activityContext, localMedia.state)
.onFailure {
val snackbarMessage = SnackbarMessage(openShareError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
else -> Unit
}
}
private fun openShareError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
UtilsR.string.error_no_compatible_app_found
} else {
StringR.string.error_unknown
}
}
} }

View file

@ -17,13 +17,13 @@
package io.element.android.features.messages.impl.media.viewer 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.LocalMedia
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
data class MediaViewerState( data class MediaViewerState(
val name: String, val mediaInfo: MediaInfo,
val mimeType: String?,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val downloadedMedia: Async<LocalMedia>, val downloadedMedia: Async<LocalMedia>,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,

View file

@ -18,8 +18,12 @@ package io.element.android.features.messages.impl.media.viewer
import android.net.Uri import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.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 import io.element.android.libraries.architecture.Async
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> { open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
@ -30,24 +34,36 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
aMediaViewerState(Async.Failure(IllegalStateException())), aMediaViewerState(Async.Failure(IllegalStateException())),
aMediaViewerState( aMediaViewerState(
Async.Success( Async.Success(
LocalMedia( LocalMedia(Uri.EMPTY, anImageInfo())
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L
)
), ),
anImageInfo(),
), ),
aMediaViewerState( aMediaViewerState(
Async.Success( Async.Success(
LocalMedia( LocalMedia(Uri.EMPTY, aVideoInfo())
Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L
)
), ),
aVideoInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, aPdfInfo())
),
aPdfInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, aFileInfo())
),
aFileInfo(),
) )
) )
} }
fun aMediaViewerState(downloadedMedia: Async<LocalMedia> = Async.Uninitialized) = MediaViewerState( fun aMediaViewerState(
name = "A media", downloadedMedia: Async<LocalMedia> = Async.Uninitialized,
mimeType = MimeTypes.IMAGE_JPEG, mediaInfo: MediaInfo = anImageInfo(),
) = MediaViewerState(
mediaInfo = mediaInfo,
thumbnailSource = null, thumbnailSource = null,
downloadedMedia = downloadedMedia, downloadedMedia = downloadedMedia,
snackbarMessage = null snackbarMessage = null

View file

@ -22,12 +22,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@ -43,15 +49,14 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import io.element.android.features.messages.impl.media.local.LocalMediaView import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog 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.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.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton 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.Scaffold
@ -102,7 +107,8 @@ fun MediaViewerView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(modifier, Scaffold(
modifier,
topBar = { topBar = {
MediaViewerTopBar( MediaViewerTopBar(
actionsEnabled = state.downloadedMedia is Async.Success, actionsEnabled = state.downloadedMedia is Async.Success,
@ -120,36 +126,48 @@ fun MediaViewerView(
} }
}, },
) { ) {
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it), .padding(it),
contentAlignment = Alignment.Center
) { ) {
if (state.downloadedMedia is Async.Failure) { if (showProgress) {
ErrorView( LinearProgressIndicator(
errorMessage = stringResource(id = StringR.string.error_unknown), Modifier
onRetry = ::onRetry, .fillMaxWidth()
onDismiss = ::onDismissError .height(2.dp)
)
} else {
Spacer(Modifier.height(2.dp))
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (state.downloadedMedia is Async.Failure) {
ErrorView(
errorMessage = stringResource(id = StringR.string.error_unknown),
onRetry = ::onRetry,
onDismiss = ::onDismissError
)
}
LocalMediaView(
localMedia = state.downloadedMedia.dataOrNull(),
info = state.mediaInfo,
onReady = ::onMediaReady
)
ThumbnailView(
thumbnailSource = state.thumbnailSource,
showThumbnail = showThumbnail,
) )
} }
LocalMediaView(
localMedia = state.downloadedMedia.dataOrNull(),
mimeType = state.mimeType,
onReady = ::onMediaReady
)
ThumbnailView(
thumbnailSource = state.thumbnailSource,
showThumbnail = showThumbnail,
showProgress = showProgress,
)
} }
} }
} }
@Composable @Composable
private fun MediaViewerTopBar( private fun MediaViewerTopBar(
actionsEnabled : Boolean, actionsEnabled: Boolean,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit, eventSink: (MediaViewerEvents) -> Unit,
) { ) {
@ -160,10 +178,10 @@ private fun MediaViewerTopBar(
IconButton( IconButton(
enabled = actionsEnabled, enabled = actionsEnabled,
onClick = { onClick = {
eventSink(MediaViewerEvents.Share) eventSink(MediaViewerEvents.OpenWith)
}, },
) { ) {
Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = string.action_share)) Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = string.action_share))
} }
IconButton( IconButton(
enabled = actionsEnabled, enabled = actionsEnabled,
@ -173,6 +191,14 @@ private fun MediaViewerTopBar(
) { ) {
Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = string.action_save)) Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = string.action_save))
} }
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.Share)
},
) {
Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = string.action_share))
}
} }
) )
} }
@ -181,7 +207,6 @@ private fun MediaViewerTopBar(
private fun ThumbnailView( private fun ThumbnailView(
thumbnailSource: MediaSource?, thumbnailSource: MediaSource?,
showThumbnail: Boolean, showThumbnail: Boolean,
showProgress: Boolean,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = showThumbnail, visible = showThumbnail,
@ -203,14 +228,6 @@ private fun ThumbnailView(
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
contentDescription = null, contentDescription = null,
) )
if (showProgress) {
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
} }
} }
} }

View file

@ -192,7 +192,7 @@ class MessageComposerPresenter @Inject constructor(
is Attachment.Media -> { is Attachment.Media -> {
sendMedia( sendMedia(
uri = attachment.localMedia.uri, uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.mimeType, mimeType = attachment.localMedia.info.mimeType,
attachmentState = attachmentState attachmentState = attachmentState
) )
} }
@ -210,12 +210,17 @@ class MessageComposerPresenter @Inject constructor(
attachmentsState.value = AttachmentsState.None attachmentsState.value = AttachmentsState.None
return return
} }
val localMedia = localMediaFactory.createFromUri(uri, mimeType, null) val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = mimeType,
name = null,
formattedFileSize = null
)
val mediaAttachment = Attachment.Media(localMedia, compressIfPossible) val mediaAttachment = Attachment.Media(localMedia, compressIfPossible)
val isPreviewable = when { val isPreviewable = when {
MimeTypes.isImage(localMedia.mimeType) -> true MimeTypes.isImage(localMedia.info.mimeType) -> true
MimeTypes.isVideo(localMedia.mimeType) -> true MimeTypes.isVideo(localMedia.info.mimeType) -> true
MimeTypes.isAudio(localMedia.mimeType) -> true MimeTypes.isAudio(localMedia.info.mimeType) -> true
else -> false else -> false
} }
attachmentsState.value = if (isPreviewable) { attachmentsState.value = if (isPreviewable) {

View file

@ -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.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument 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.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@ -51,11 +52,12 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemImageContent( TimelineItemImageContent(
body = messageType.body, body = messageType.body,
mediaSource = messageType.source, mediaSource = messageType.source,
mimeType = messageType.info?.mimetype, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
blurhash = messageType.info?.blurhash, blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
) )
} }
is VideoMessageType -> { is VideoMessageType -> {
@ -64,22 +66,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
body = messageType.body, body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source, videoSource = messageType.source,
mimeType = messageType.info?.mimetype, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
duration = messageType.info?.duration ?: 0L, duration = messageType.info?.duration ?: 0L,
blurHash = messageType.info?.blurhash, blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
) )
} }
is FileMessageType -> TimelineItemFileContent( is FileMessageType -> TimelineItemFileContent(
body = messageType.body, body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source, fileSource = messageType.source,
mimeType = messageType.info?.mimetype, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = messageType.info?.size?.let { formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0)
fileSizeFormatter.format(it)
},
) )
is NoticeMessageType -> TimelineItemNoticeContent( is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body, body = messageType.body,

View file

@ -16,23 +16,17 @@
package io.element.android.features.messages.impl.timeline.model.event 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 import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemFileContent( data class TimelineItemFileContent(
val body: String, val body: String,
val fileSource: MediaSource, val fileSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val formattedFileSize: String?, val formattedFileSize: String,
val mimeType: String?, val mimeType: String,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
override val type: String = "TimelineItemFileContent" override val type: String = "TimelineItemFileContent"
private val fileExtension = body.substringAfterLast('.', "").uppercase() val fileExtensionAndSize = formatFileExtensionAndSize(body, formattedFileSize)
val fileExtensionAndSize = buildString {
append(fileExtension)
if (formattedFileSize != null) {
append(' ')
append("($formattedFileSize)")
}
}
} }

View file

@ -21,7 +21,8 @@ import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemImageContent( data class TimelineItemImageContent(
val body: String, val body: String,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val mimeType: String?, val formattedFileSize: String,
val mimeType: String,
val blurhash: String?, val blurhash: String?,
val width: Int?, val width: Int?,
val height: Int?, val height: Int?,

View file

@ -36,5 +36,6 @@ fun aTimelineItemImageContent() = TimelineItemImageContent(
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
width = null, width = null,
height = 300, height = 300,
aspectRatio = 0.5f aspectRatio = 0.5f,
formattedFileSize = "4MB"
) )

View file

@ -27,7 +27,8 @@ data class TimelineItemVideoContent(
val blurHash: String?, val blurHash: String?,
val height: Int?, val height: Int?,
val width: Int?, val width: Int?,
val mimeType: String?, val mimeType: String,
val formattedFileSize: String,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent" override val type: String = "TimelineItemImageContent"
} }

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> { open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
@ -37,5 +38,6 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
videoSource = MediaSource(""), videoSource = MediaSource(""),
height = 300, height = 300,
width = 150, width = 150,
mimeType = null mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB"
) )

View file

@ -20,7 +20,7 @@ import android.net.Uri
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import io.element.android.features.messages.impl.attachments.Attachment 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.LocalMedia
import io.mockk.mockk import io.element.android.features.messages.impl.media.local.MediaInfo
fun aLocalMedia( fun aLocalMedia(
uri: Uri, uri: Uri,
@ -29,9 +29,11 @@ fun aLocalMedia(
size: Long = 1000, size: Long = 1000,
) = LocalMedia( ) = LocalMedia(
uri = uri, uri = uri,
mimeType = mimeType, info = MediaInfo(
name = name, mimeType = mimeType,
size = size, name = name,
formattedFileSize = "${size}B",
)
) )
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(