[Media upload] Upload image, video and files (#411)

* Add media upload

* Display media upload error messages using a Snackbar.
This commit is contained in:
Jorge Martin Espinosa 2023-05-11 17:56:13 +02:00 committed by GitHub
parent 1765398eb1
commit 89b9db3be6
24 changed files with 373 additions and 77 deletions

View file

@ -41,6 +41,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ -52,6 +54,7 @@ class MessagesPresenter @Inject constructor(
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessagesState> {
@Composable
@ -71,6 +74,8 @@ class MessagesPresenter @Inject constructor(
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
LaunchedEffect(syncUpdateFlow) {
roomAvatar.value =
AvatarData(
@ -97,6 +102,7 @@ class MessagesPresenter @Inject constructor(
timelineState = timelineState,
actionListState = actionListState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents
)
}

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
@Immutable
@ -32,5 +33,6 @@ data class MessagesState(
val timelineState: TimelineState,
val actionListState: ActionListState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MessagesEvents) -> Unit
)

View file

@ -52,5 +52,6 @@ fun aMessagesState() = MessagesState(
),
actionListState = anActionListState(),
hasNetworkConnection = true,
snackbarMessage = null,
eventSink = {}
)

View file

@ -46,6 +46,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -94,7 +95,6 @@ fun MessagesView(
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
val composerState = state.composerState
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
ModalBottomSheetValue.Expanded
@ -110,6 +110,19 @@ fun MessagesView(
}
}
val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
if (snackbarMessageText != null) {
SideEffect {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = snackbarMessageText,
duration = state.snackbarMessage.duration
)
}
}
}
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current

View file

@ -32,6 +32,8 @@ import io.element.android.libraries.core.data.toStableCharSequence
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.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
@ -40,11 +42,13 @@ 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.MediaUploadInfo
import io.element.android.libraries.textcomposer.MessageComposerMode
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(
@ -53,6 +57,7 @@ class MessageComposerPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
private val mediaPreProcessor: MediaPreProcessor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessageComposerState> {
@Composable
@ -60,6 +65,7 @@ class MessageComposerPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
if (uri == null) return@registerGalleryPicker
Timber.d("Media picked from $uri")
// We don't know which type of media was retrieved, so we need this check
val mediaType = when {
@ -67,22 +73,25 @@ class MessageComposerPresenter @Inject constructor(
mimeType.isMimeTypeVideo() -> MediaType.Video
else -> error("MimeType must be either image/* or video/*")
}
localCoroutineScope.handleMediaPreProcessing(uri, mediaType)
appCoroutineScope.sendMedia(uri, mediaType)
})
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
if (uri == null) return@registerFilePicker
Timber.d("File picked from $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File)
appCoroutineScope.sendMedia(uri, MediaType.File)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
if (uri == null) return@registerCameraPhotoPicker
Timber.d("Photo saved at $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true)
appCoroutineScope.sendMedia(uri, MediaType.Image, deleteOriginal = true)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
if (uri == null) return@registerCameraVideoPicker
Timber.d("Video saved at $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true)
appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true)
}
val isFullScreen = rememberSaveable {
@ -181,14 +190,44 @@ class MessageComposerPresenter @Inject constructor(
}
}
private fun CoroutineScope.handleMediaPreProcessing(
uri: Uri?,
private fun CoroutineScope.sendMedia(
uri: Uri,
mediaType: MediaType,
deleteOriginal: Boolean = false
) = launch {
if (uri == null) return@launch
runCatching {
val info = handleMediaPreProcessing(uri, mediaType, deleteOriginal).getOrNull() ?: return@runCatching
when (info) {
is MediaUploadInfo.Image -> {
room.sendImage(info.file, info.thumbnailInfo.file, info.info)
}
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
is MediaUploadInfo.Video -> {
room.sendVideo(info.file, info.thumbnailInfo.file, info.info)
}
is MediaUploadInfo.AnyFile -> {
room.sendFile(info.file, info.info)
}
else -> error("Unexpected MediaUploadInfo format: $info")
}.getOrThrow()
}.onFailure {
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_sending))
Timber.e(it, "Couldn't upload media")
}.onSuccess {
Timber.d("Media uploaded")
}
}
private suspend fun handleMediaPreProcessing(
uri: Uri,
mediaType: MediaType,
deleteOriginal: Boolean,
): Result<MediaUploadInfo> {
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
Timber.d("Pre-processed media result: $result")
return result.onFailure {
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_processing))
}
}
}