Media : branch upload to preview screen (need improvement)

This commit is contained in:
ganfra
2023-05-17 08:44:35 +02:00
parent 2108c2bc21
commit cc7d71af80
15 changed files with 237 additions and 92 deletions

View File

@@ -14,11 +14,12 @@
* limitations under the License.
*/
package io.element.android.libraries.mediaupload.api
package io.element.android.features.messages.impl.attachments.preview
sealed interface MediaType {
object Image : MediaType
object Video : MediaType
object Audio : MediaType
object File : MediaType
import androidx.compose.runtime.Immutable
@Immutable
sealed interface AttachmentsPreviewEvents {
object SendAttachment : AttachmentsPreviewEvents
object ClearSendState : AttachmentsPreviewEvents
}

View File

@@ -16,15 +16,32 @@
package io.element.android.features.messages.impl.attachments.preview
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.sendMedia
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment,
private val room: MatrixRoom,
private val mediaPreProcessor: MediaPreProcessor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<AttachmentsPreviewState> {
@AssistedFactory
@@ -35,8 +52,54 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
@Composable
override fun present(): AttachmentsPreviewState {
val coroutineScope = rememberCoroutineScope()
val sendActionState = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState)
AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = Async.Uninitialized
}
}
return AttachmentsPreviewState(
attachment = attachment,
sendActionState = sendActionState.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
sendActionState: MutableState<Async<Unit>>,
) = launch {
when (attachment) {
is Attachment.Media -> {
sendMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.mimeType,
deleteOriginal = false,
sendActionState = sendActionState
)
}
}
}
private suspend fun sendMedia(
uri: Uri,
mimeType: String,
deleteOriginal: Boolean = false,
sendActionState: MutableState<Async<Unit>>,
) {
suspend {
mediaPreProcessor
.process(uri, mimeType, deleteOriginal)
.flatMap { info ->
room.sendMedia(info)
}
}.executeResult(sendActionState)
}
}

View File

@@ -17,7 +17,10 @@
package io.element.android.features.messages.impl.attachments.preview
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async
data class AttachmentsPreviewState(
val attachment: Attachment,
val sendActionState: Async<Unit>,
val eventSink: (AttachmentsPreviewEvents) -> Unit
)

View File

@@ -20,17 +20,23 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.mimetype.MimeTypes
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf(
aAttachmentsPreviewState(),
anAttachmentsPreviewState(),
anAttachmentsPreviewState(sendActionState = Async.Loading()),
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())),
// Add other states here
)
}
fun aAttachmentsPreviewState() = AttachmentsPreviewState(
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("".toUri(), mimeType = null)
)
localMedia = LocalMedia("".toUri(), mimeType = MimeTypes.OctetStream),
),
sendActionState = sendActionState,
eventSink = {}
)

View File

@@ -29,17 +29,23 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
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 io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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.TextButton
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.ui.strings.R as StringsR
@Composable
@@ -49,39 +55,93 @@ fun AttachmentsPreviewView(
modifier: Modifier = Modifier,
) {
fun onSendClicked() {
fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
}
fun postClearSendState() {
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
}
if (state.sendActionState is Async.Success) {
LaunchedEffect(state.sendActionState) {
onDismiss()
}
}
Scaffold(modifier) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Spacer(
modifier = Modifier.height(80.dp)
Box {
AttachmentPreviewContent(
attachment = state.attachment,
onSendClicked = ::postSendAttachment,
onDismiss = onDismiss
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
when (state.attachment) {
is Attachment.Media -> LocalMediaView(
localMedia = state.attachment.localMedia
)
}
}
AttachmentsPreviewBottomActions(
onCancelClicked = onDismiss,
onSendClicked = ::onSendClicked,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 120.dp)
.padding(all = 24.dp)
AttachmentSendStateView(
sendActionState = state.sendActionState,
onRetryClicked = ::postSendAttachment,
onRetryDismissed = ::postClearSendState
)
}
}
}
@Composable
private fun AttachmentSendStateView(
sendActionState: Async<Unit>,
onRetryDismissed: () -> Unit,
onRetryClicked: () -> Unit
) {
when (sendActionState) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = R.string.common_loading))
}
is Async.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onRetryDismissed,
onRetry = onRetryClicked
)
}
else -> Unit
}
}
@Composable
private fun AttachmentPreviewContent(
attachment: Attachment,
onSendClicked: () -> Unit,
onDismiss: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Spacer(
modifier = Modifier.height(80.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
when (attachment) {
is Attachment.Media -> LocalMediaView(
localMedia = attachment.localMedia
)
}
}
AttachmentsPreviewBottomActions(
onCancelClicked = onDismiss,
onSendClicked = onSendClicked,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 120.dp)
.padding(all = 24.dp)
)
}
}
@Composable
private fun AttachmentsPreviewBottomActions(
onCancelClicked: () -> Unit,

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 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.attachments.preview.error
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.ui.strings.R
fun sendAttachmentError(
throwable: Throwable
): Int {
return if (throwable is MediaPreProcessor.Failure) {
R.string.screen_media_upload_preview_error_failed_processing
} else {
R.string.screen_media_upload_preview_error_failed_sending
}
}

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.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
@@ -30,7 +31,7 @@ class AndroidLocalMediaFactory @Inject constructor(
override fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia? {
if (uri == null) return null
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri)
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri) ?: MimeTypes.OctetStream
return LocalMedia(uri, resolvedMimeType)
}
}

View File

@@ -17,13 +17,14 @@
package io.element.android.features.messages.impl.media.local
import android.net.Uri
import io.element.android.libraries.core.mimetype.MimeTypes
class FakeLocalMediaFactory() : LocalMediaFactory {
var mimeType: String? = null
var fallbackMimeType: String = MimeTypes.OctetStream
override fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia? {
if (uri == null) return null
return LocalMedia(uri, mimeType)
return LocalMedia(uri, mimeType ?: fallbackMimeType)
}
}

View File

@@ -23,5 +23,5 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class LocalMedia(
val uri: Uri,
val mimeType: String?,
val mimeType: String,
) : Parcelable

View File

@@ -19,5 +19,10 @@ package io.element.android.features.messages.impl.media.local
import android.net.Uri
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.
*
*/
fun createFromUri(uri: Uri?, mimeType: String?): LocalMedia?
}

View File

@@ -34,7 +34,6 @@ import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -42,15 +41,11 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.sendMedia
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
@SingleIn(RoomScope::class)
class MessageComposerPresenter @Inject constructor(
@@ -59,8 +54,6 @@ class MessageComposerPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
private val localMediaFactory: LocalMediaFactory,
private val mediaPreProcessor: MediaPreProcessor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessageComposerState> {
@SuppressLint("UnsafeOptInUsageError")
@@ -186,26 +179,4 @@ class MessageComposerPresenter @Inject constructor(
}
}
private fun CoroutineScope.sendMedia(
uri: Uri,
mediaType: MediaType,
deleteOriginal: Boolean = false
) = launch {
mediaPreProcessor.process(uri, mediaType, deleteOriginal)
.map { info ->
room.sendMedia(info)
}
.onSuccess {
Timber.d("onSuccess sending media")
}.onFailure { failure ->
Timber.e(failure, "onfailure sending media: $failure")
val snackbarMessage = if (failure is MediaPreProcessor.Failure) {
StringR.string.screen_media_upload_preview_error_failed_processing
} else {
StringR.string.screen_media_upload_preview_error_failed_sending
}
snackbarDispatcher.post(SnackbarMessage(snackbarMessage))
}
}
}

View File

@@ -25,3 +25,16 @@ inline fun <R, T : R> Result<T>.mapFailure(transform: (exception: Throwable) ->
else -> Result.failure(transform(exception))
}
}
/**
* Can be used to transform some Throwable into some other.
*/
inline fun <R, T> Result<R>.flatMap(transform: (R) -> Result<T>): Result<T> {
return when (val exception = exceptionOrNull()) {
null -> mapCatching(transform).fold(
onSuccess = { it },
onFailure = { Result.failure(it) }
)
else -> Result.failure(exception)
}
}

View File

@@ -20,13 +20,13 @@ import android.net.Uri
interface MediaPreProcessor {
/**
* Given a [uri] and [mediaType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata.
* Given a [uri] and [mimeType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata.
* If [deleteOriginal] is `true`, the file reference by the [uri] will be automatically deleted too when this process finishes.
* @return a [Result] with the [MediaUploadInfo] containing all the info needed to begin the upload.
*/
suspend fun process(
uri: Uri,
mediaType: MediaType,
mimeType: String,
deleteOriginal: Boolean = false
): Result<MediaUploadInfo>

View File

@@ -27,7 +27,9 @@ import io.element.android.libraries.androidutils.media.runAndRelease
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.AudioInfo
@@ -37,7 +39,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import kotlinx.coroutines.Dispatchers
@@ -53,7 +54,7 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(AppScope::class)
class MediaPreProcessorImpl @Inject constructor(
class AndroidMediaPreProcessor @Inject constructor(
@ApplicationContext private val context: Context,
private val imageCompressor: ImageCompressor,
private val videoCompressor: VideoCompressor,
@@ -89,27 +90,18 @@ class MediaPreProcessorImpl @Inject constructor(
override suspend fun process(
uri: Uri,
mediaType: MediaType,
mimeType: String,
deleteOriginal: Boolean,
): Result<MediaUploadInfo> = runCatching {
// Camera returns an 'octet-stream' mimetype, so it needs to be overridden
val mimeType = contentResolver.getType(uri)
val mimeTypeOrDefault = if (mimeType == MimeTypes.OctetStream) {
when (mediaType) {
MediaType.Image -> MimeTypes.Jpeg
MediaType.Video -> MimeTypes.Mp4
MediaType.Audio -> MimeTypes.Ogg
else -> mimeType
}
} else {
mimeType
}
val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video)
val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) {
when (mediaType) {
MediaType.Image -> processImage(uri)
MediaType.Video -> processVideo(uri, mimeTypeOrDefault)
MediaType.Audio -> processAudio(uri, mimeTypeOrDefault)
val compressBeforeSending = (
mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) ||
mimeType.isMimeTypeVideo()
val result = if (compressBeforeSending) {
when {
mimeType.isMimeTypeImage() -> processImage(uri)
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType)
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
else -> error("Cannot compress file of type: $mimeType")
}
} else {

View File

@@ -19,7 +19,6 @@ package io.element.android.libraries.mediaupload.test
import android.net.Uri
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import java.io.File
@@ -37,7 +36,7 @@ class FakeMediaPreProcessor : MediaPreProcessor {
)
)
override suspend fun process(uri: Uri, mediaType: MediaType, deleteOriginal: Boolean): Result<MediaUploadInfo> = result
override suspend fun process(uri: Uri, mimeType: String, deleteOriginal: Boolean): Result<MediaUploadInfo> = result
fun givenResult(value: Result<MediaUploadInfo>) {
this.result = value