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 fa63ed1faf
commit e322ba1b32
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.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -64,10 +65,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class MediaViewer(
val title: String,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val mimeType: String?,
) : NavTarget
@Parcelize
@ -100,10 +100,9 @@ class MessagesFlowNode @AssistedInject constructor(
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(
name = navTarget.title,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
mimeType = navTarget.mimeType,
)
createNode<MediaViewerNode>(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)
}

View file

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

View file

@ -20,8 +20,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.anImageInfo
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.mimetype.MimeTypes
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState>
@ -34,7 +34,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L),
localMedia = LocalMedia("path".toUri(), anImageInfo()),
compressIfPossible = true
),
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.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
@ -60,16 +61,17 @@ class AndroidLocalMediaActions @Inject constructor(
}
}
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun share(activityContext: Context, localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatching {
val authority = "${buildMeta.applicationId}.fileprovider"
val uriFromFileProvider = FileProvider.getUriForFile(context, authority, localMedia.toFile())
val shareMediaIntent = Intent(Intent.ACTION_VIEW)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
.setDataAndType(uriFromFileProvider, localMedia.mimeType)
val shareableUri = localMedia.toShareableUri()
val shareMediaIntent = Intent(Intent.ACTION_SEND)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_STREAM, shareableUri)
.setTypeAndNormalize(localMedia.info.mimeType)
withContext(coroutineDispatchers.main) {
context.startActivity(shareMediaIntent, null)
val intent = Intent.createChooser(shareMediaIntent, null)
activityContext.startActivity(intent)
}
}.onSuccess {
Timber.v("Share media succeed")
@ -78,11 +80,33 @@ class AndroidLocalMediaActions @Inject constructor(
}
}
override suspend fun open(activityContext: Context, localMedia: LocalMedia): Result<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)
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.name)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.mimeType)
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.name)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
@ -99,7 +123,7 @@ class AndroidLocalMediaActions @Inject constructor(
private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
localMedia.name ?: ""
localMedia.info.name
)
localMedia.openStream()?.use { input ->
FileOutputStream(target).use { output ->

View file

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

View file

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

View file

@ -16,6 +16,8 @@
package io.element.android.features.messages.impl.media.local
import android.content.Context
interface LocalMediaActions {
/**
* Will save the current media to the Downloads directory.
@ -27,6 +29,12 @@ interface LocalMediaActions {
* Will try to find a suitable application to share the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun share(localMedia: LocalMedia): Result<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 {
/**
* This method will create a [LocalMedia] with the given [uri] and [mimeType]
* If the [mimeType] is null, it'll try to read it from the content.
* If the [name] is null, it'll try to read it from the content.
* This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize
* If any of those params are null, it'll try to read them from the content.
*/
fun createFromUri(
uri: Uri,
mimeType: String?,
name: String?,
formattedFileSize: String?
): LocalMedia
}
fun LocalMediaFactory.createFromMediaFile(
mediaFile: MediaFile,
mimeType: String?,
name: String?
mediaInfo: MediaInfo,
): LocalMedia {
val uri = mediaFile.toFile().toUri()
return createFromUri(uri = uri, mimeType = mimeType, name = name)
return createFromUri(uri = uri, mimeType = mediaInfo.mimeType, name = mediaInfo.name, formattedFileSize = mediaInfo.formattedFileSize)
}

View file

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

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 {
object SaveOnDisk: MediaViewerEvents
object Share: MediaViewerEvents
object OpenWith: MediaViewerEvents
object RetryLoading : MediaViewerEvents
object ClearLoadingError : MediaViewerEvents
}

View file

@ -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()

View file

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

View file

@ -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<MediaViewerState> {
@ -30,24 +34,36 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
aMediaViewerState(Async.Failure(IllegalStateException())),
aMediaViewerState(
Async.Success(
LocalMedia(
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L
)
LocalMedia(Uri.EMPTY, anImageInfo())
),
anImageInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(
Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L
)
LocalMedia(Uri.EMPTY, aVideoInfo())
),
aVideoInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, aPdfInfo())
),
aPdfInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, aFileInfo())
),
aFileInfo(),
)
)
}
fun aMediaViewerState(downloadedMedia: Async<LocalMedia> = Async.Uninitialized) = MediaViewerState(
name = "A media",
mimeType = MimeTypes.IMAGE_JPEG,
fun aMediaViewerState(
downloadedMedia: Async<LocalMedia> = Async.Uninitialized,
mediaInfo: MediaInfo = anImageInfo(),
) = MediaViewerState(
mediaInfo = mediaInfo,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
snackbarMessage = null

View file

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

View file

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

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.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,

View file

@ -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)
}

View file

@ -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?,

View file

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

View file

@ -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"
}

View file

@ -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<TimelineItemVideoContent> {
@ -37,5 +38,6 @@ fun aTimelineItemVideoContent() = TimelineItemVideoContent(
videoSource = MediaSource(""),
height = 300,
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 io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.mockk.mockk
import io.element.android.features.messages.impl.media.local.MediaInfo
fun aLocalMedia(
uri: Uri,
@ -29,9 +29,11 @@ fun aLocalMedia(
size: Long = 1000,
) = LocalMedia(
uri = uri,
mimeType = mimeType,
name = name,
size = size,
info = MediaInfo(
mimeType = mimeType,
name = name,
formattedFileSize = "${size}B",
)
)
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(