Merge pull request #551 from vector-im/feature/fga/media_viewer_actions
Feature/fga/media viewer actions
This commit is contained in:
commit
b5ec693886
75 changed files with 1105 additions and 360 deletions
|
|
@ -17,6 +17,9 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- To be able to install APK from the application -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ElementXApplication"
|
android:name=".ElementXApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@ koverMerged {
|
||||||
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
|
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
|
||||||
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
|
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
|
||||||
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
||||||
|
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||||
}
|
}
|
||||||
bound {
|
bound {
|
||||||
minValue = 90
|
minValue = 90
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
|
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -94,23 +95,11 @@ fun MessagesView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
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
|
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
||||||
val localView = LocalView.current
|
val localView = LocalView.current
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,21 +20,27 @@ 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.MediaInfo
|
||||||
|
import io.element.android.features.messages.impl.media.local.aFileInfo
|
||||||
|
import io.element.android.features.messages.impl.media.local.aVideoInfo
|
||||||
|
import io.element.android.features.messages.impl.media.local.anImageInfo
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.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>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
anAttachmentsPreviewState(),
|
anAttachmentsPreviewState(),
|
||||||
|
anAttachmentsPreviewState(mediaInfo = aFileInfo()),
|
||||||
anAttachmentsPreviewState(sendActionState = Async.Loading()),
|
anAttachmentsPreviewState(sendActionState = Async.Loading()),
|
||||||
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())),
|
anAttachmentsPreviewState(sendActionState = Async.Failure(RuntimeException())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
|
fun anAttachmentsPreviewState(
|
||||||
|
mediaInfo: MediaInfo = anImageInfo(),
|
||||||
|
sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
|
||||||
attachment = Attachment.Media(
|
attachment = Attachment.Media(
|
||||||
localMedia = LocalMedia("path".toUri(), MimeTypes.Jpeg, "an image", 1000L),
|
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||||
compressIfPossible = true
|
compressIfPossible = true
|
||||||
),
|
),
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,8 @@ private fun AttachmentPreviewContent(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
when (attachment) {
|
when (attachment) {
|
||||||
is Attachment.Media -> LocalMediaView(
|
is Attachment.Media -> LocalMediaView(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.helper
|
||||||
|
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
|
||||||
|
fun formatFileExtensionAndSize(name: String, size: String?): String {
|
||||||
|
val fileExtension = name.substringAfterLast('.', "")
|
||||||
|
// Makes sure the extension is known by the system, otherwise default to binary extension.
|
||||||
|
val safeExtension = if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
|
||||||
|
fileExtension.uppercase()
|
||||||
|
} else {
|
||||||
|
"BIN"
|
||||||
|
}
|
||||||
|
return buildString {
|
||||||
|
append(safeExtension)
|
||||||
|
if (size != null) {
|
||||||
|
append(' ')
|
||||||
|
append("($size)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class AndroidLocalMediaActions @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : LocalMediaActions {
|
||||||
|
|
||||||
|
private var activityContext: Context? = null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Configure() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return DisposableEffect(Unit) {
|
||||||
|
activityContext = context
|
||||||
|
onDispose {
|
||||||
|
activityContext = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
|
||||||
|
runCatching {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
saveOnDiskUsingMediaStore(localMedia)
|
||||||
|
} else {
|
||||||
|
saveOnDiskUsingExternalStorageApi(localMedia)
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
Timber.v("Save on disk succeed")
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Save on disk failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
|
||||||
|
runCatching {
|
||||||
|
val shareableUri = localMedia.toShareableUri()
|
||||||
|
val shareMediaIntent = Intent(Intent.ACTION_SEND)
|
||||||
|
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.putExtra(Intent.EXTRA_STREAM, shareableUri)
|
||||||
|
.setTypeAndNormalize(localMedia.info.mimeType)
|
||||||
|
withContext(coroutineDispatchers.main) {
|
||||||
|
val intent = Intent.createChooser(shareMediaIntent, null)
|
||||||
|
activityContext!!.startActivity(intent)
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
Timber.v("Share media succeed")
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Share media failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun open(localMedia: LocalMedia): Result<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.info.name)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
}
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||||
|
if (outputUri != null) {
|
||||||
|
localMedia.openStream()?.use { input ->
|
||||||
|
resolver.openOutputStream(outputUri).use { output ->
|
||||||
|
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) {
|
||||||
|
val target = File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||||
|
localMedia.info.name
|
||||||
|
)
|
||||||
|
localMedia.openStream()?.use { input ->
|
||||||
|
FileOutputStream(target).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalMedia.openStream(): InputStream? {
|
||||||
|
return context.contentResolver.openInputStream(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to extract a file from the uri.
|
||||||
|
*/
|
||||||
|
private fun LocalMedia.toFile(): File {
|
||||||
|
return uri.toFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,33 +20,50 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
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.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
import io.element.android.libraries.matrix.api.media.toFile
|
||||||
import javax.inject.Inject
|
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 createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
|
override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia {
|
||||||
val uri = mediaFile.path().toUri()
|
val uri = mediaFile.toFile().toUri()
|
||||||
return createFromUri(uri, mimeType)
|
return createFromUri(
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mediaInfo.mimeType,
|
||||||
|
name = mediaInfo.name,
|
||||||
|
formattedFileSize = mediaInfo.formattedFileSize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia {
|
override fun createFromUri(
|
||||||
val resolvedMimeType = mimeType ?: context.contentResolver.getType(uri) ?: MimeTypes.OctetStream
|
uri: Uri,
|
||||||
val fileName = context.getFileName(uri)
|
mimeType: String?,
|
||||||
val fileSize = context.getFileSize(uri)
|
name: String?,
|
||||||
|
formattedFileSize: String?
|
||||||
|
): LocalMedia {
|
||||||
|
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
||||||
|
val fileName = name ?: context.getFileName(uri) ?: ""
|
||||||
|
val fileSize = formattedFileSize ?: fileSizeFormatter.format(context.getFileSize(uri))
|
||||||
return LocalMedia(
|
return LocalMedia(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = resolvedMimeType,
|
info = MediaInfo(
|
||||||
name = fileName,
|
mimeType = resolvedMimeType,
|
||||||
size = fileSize
|
name = fileName,
|
||||||
|
formattedFileSize = fileSize
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,22 +19,11 @@ 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 {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This tries to convert the uri to a file if applicable, otherwise keep it as uri.
|
|
||||||
*/
|
|
||||||
@IgnoredOnParcel val model: Any by lazy {
|
|
||||||
UriToFileMapper.map(uri) ?: uri
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
interface LocalMediaActions {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Configure()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will save the current media to the Downloads directory.
|
||||||
|
* The [LocalMedia.uri] needs to have a file scheme.
|
||||||
|
*/
|
||||||
|
suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will try to find a suitable application to open the media with.
|
||||||
|
* The [LocalMedia.uri] needs to have a file scheme.
|
||||||
|
*/
|
||||||
|
suspend fun open(localMedia: LocalMedia): Result<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -22,13 +22,21 @@ import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
interface LocalMediaFactory {
|
interface LocalMediaFactory {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method will create a [LocalMedia] with the given [MediaFile] and [mimeType].
|
* This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo].
|
||||||
*/
|
*/
|
||||||
fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia
|
fun createFromMediaFile(
|
||||||
|
mediaFile: MediaFile,
|
||||||
|
mediaInfo: MediaInfo,
|
||||||
|
): LocalMedia
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method will create a [LocalMedia] with the given [uri] and [mimeType]
|
* 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.
|
||||||
*/
|
*/
|
||||||
fun createFromUri(uri: Uri, mimeType: String?): LocalMedia
|
fun createFromUri(
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String?,
|
||||||
|
name: String?,
|
||||||
|
formattedFileSize: String?
|
||||||
|
): LocalMedia
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,37 @@
|
||||||
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Attachment
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.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.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
|
@ -36,6 +55,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 +63,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
import io.element.android.libraries.core.mimetype.MimeTypes.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.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
import 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,39 +78,45 @@ import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
fun LocalMediaView(
|
fun LocalMediaView(
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
mimeType: String? = localMedia?.mimeType,
|
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
||||||
onReady: () -> Unit = {},
|
mediaInfo: MediaInfo? = localMedia?.info,
|
||||||
) {
|
) {
|
||||||
val zoomableState = rememberZoomableState(
|
val zoomableState = rememberZoomableState(
|
||||||
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
||||||
)
|
)
|
||||||
|
val mimeType = mediaInfo?.mimeType
|
||||||
when {
|
when {
|
||||||
mimeType.isMimeTypeImage() -> MediaImageView(
|
mimeType.isMimeTypeImage() -> MediaImageView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
zoomableState = zoomableState,
|
zoomableState = zoomableState,
|
||||||
onReady = onReady,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
onReady = onReady,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
zoomableState = zoomableState,
|
zoomableState = zoomableState,
|
||||||
onReady = onReady,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
else -> Unit
|
else -> MediaFileView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
|
uri = localMedia?.uri,
|
||||||
|
info = mediaInfo,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaImageView(
|
private fun MediaImageView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
zoomableState: ZoomableState,
|
zoomableState: ZoomableState,
|
||||||
onReady: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (LocalInspectionMode.current) {
|
if (LocalInspectionMode.current) {
|
||||||
|
|
@ -98,15 +127,11 @@ private fun MediaImageView(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
||||||
LaunchedEffect(zoomableImageState.isImageDisplayed) {
|
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
|
||||||
if (zoomableImageState.isImageDisplayed) {
|
|
||||||
onReady()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ZoomableAsyncImage(
|
ZoomableAsyncImage(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
state = zoomableImageState,
|
state = zoomableImageState,
|
||||||
model = localMedia?.model,
|
model = localMedia?.uri,
|
||||||
contentDescription = "Image",
|
contentDescription = "Image",
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
|
|
@ -116,14 +141,14 @@ private fun MediaImageView(
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaVideoView(
|
fun MediaVideoView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
onReady: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val playerListener = object : Player.Listener {
|
val playerListener = object : Player.Listener {
|
||||||
override fun onRenderedFirstFrame() {
|
override fun onRenderedFirstFrame() {
|
||||||
onReady()
|
localMediaViewState.isReady = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val exoPlayer = remember {
|
val exoPlayer = remember {
|
||||||
|
|
@ -170,19 +195,64 @@ fun MediaVideoView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaPDFView(
|
fun MediaPDFView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
zoomableState: ZoomableState,
|
zoomableState: ZoomableState,
|
||||||
onReady: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val pdfViewerState = rememberPdfViewerState(
|
val pdfViewerState = rememberPdfViewerState(
|
||||||
model = localMedia?.model,
|
model = localMedia?.uri,
|
||||||
zoomableState = zoomableState
|
zoomableState = zoomableState
|
||||||
)
|
)
|
||||||
LaunchedEffect(pdfViewerState.isLoaded) {
|
localMediaViewState.isReady = pdfViewerState.isLoaded
|
||||||
if (pdfViewerState.isLoaded) {
|
|
||||||
onReady()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
|
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaFileView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
|
uri: Uri?,
|
||||||
|
info: MediaInfo?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
localMediaViewState.isReady = uri != null
|
||||||
|
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.onBackground),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Attachment,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.rotate(-45f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (info != null) {
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = info.name,
|
||||||
|
maxLines = 2,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = ElementTheme.colors.gray1400
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = formatFileExtensionAndSize(info.name, info.formattedFileSize),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = ElementTheme.colors.gray1400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class LocalMediaViewState {
|
||||||
|
var isReady: Boolean by mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberLocalMediaViewState(): LocalMediaViewState {
|
||||||
|
return remember {
|
||||||
|
LocalMediaViewState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.media.local
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class MediaInfo(
|
||||||
|
val name: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val formattedFileSize: String,
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
fun anImageInfo(): MediaInfo = MediaInfo(
|
||||||
|
"an image file.jpg", MimeTypes.Jpeg, "4MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aVideoInfo(): MediaInfo = MediaInfo(
|
||||||
|
"a video file.mp4", MimeTypes.Mp4, "14MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aPdfInfo(): MediaInfo = MediaInfo(
|
||||||
|
"a pdf file.pdf", MimeTypes.Pdf, "23MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aFileInfo(): MediaInfo = MediaInfo(
|
||||||
|
"an apk file.apk", MimeTypes.Apk, "50MB"
|
||||||
|
)
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.local
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.net.Uri
|
|
||||||
import io.element.android.libraries.androidutils.uri.ASSET_FILE_PATH_ROOT
|
|
||||||
import io.element.android.libraries.androidutils.uri.firstPathSegment
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tries to convert a URI to a File.
|
|
||||||
* Extracted from Coil [coil.map.FileUriMapper]
|
|
||||||
*/
|
|
||||||
object UriToFileMapper {
|
|
||||||
|
|
||||||
fun map(data: Uri): File? {
|
|
||||||
if (!isApplicable(data)) return null
|
|
||||||
return if (data.scheme == ContentResolver.SCHEME_FILE) {
|
|
||||||
data.path?.let(::File)
|
|
||||||
} else {
|
|
||||||
// If the scheme is not "file", it's null, representing a literal path on disk.
|
|
||||||
// Assume the entire input, regardless of any reserved characters, is valid.
|
|
||||||
File(data.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isApplicable(data: Uri): Boolean {
|
|
||||||
return !isAssetUri(data) &&
|
|
||||||
data.scheme.let { it == null || it == ContentResolver.SCHEME_FILE } &&
|
|
||||||
data.path.orEmpty().startsWith('/') && data.firstPathSegment != null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isAssetUri(uri: Uri): Boolean {
|
|
||||||
return uri.scheme == ContentResolver.SCHEME_FILE && uri.firstPathSegment == ASSET_FILE_PATH_ROOT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -43,11 +43,11 @@ class PdfRendererManager(
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
|
pdfRenderer = PdfRenderer(parcelFileDescriptor).apply {
|
||||||
(0 until pageCount).map { pageIndex ->
|
// Preload just 3 pages so we can render faster
|
||||||
PdfPage(width, pageIndex, mutex, this, coroutineScope)
|
val firstPages = loadPages(from = 0, to = 3)
|
||||||
}.also {
|
mutablePdfPages.value = firstPages
|
||||||
mutablePdfPages.value = it
|
val nextPages = loadPages(from = 3, to = pageCount)
|
||||||
}
|
mutablePdfPages.value = firstPages + nextPages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -65,4 +65,10 @@ class PdfRendererManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun PdfRenderer.loadPages(from: Int, to: Int): List<PdfPage> {
|
||||||
|
return (from until minOf(to, pageCount)).map { pageIndex ->
|
||||||
|
PdfPage(width, pageIndex, mutex, this, coroutineScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
package io.element.android.features.messages.impl.media.viewer
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
sealed interface MediaViewerEvents {
|
sealed interface MediaViewerEvents {
|
||||||
|
object SaveOnDisk: MediaViewerEvents
|
||||||
|
object Share: MediaViewerEvents
|
||||||
|
object OpenWith: MediaViewerEvents
|
||||||
object RetryLoading : MediaViewerEvents
|
object RetryLoading : MediaViewerEvents
|
||||||
object ClearLoadingError : MediaViewerEvents
|
object ClearLoadingError : MediaViewerEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor(
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
|
onBackPressed = this::navigateUp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.viewer
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
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
|
||||||
|
|
@ -28,18 +29,26 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
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.LocalMediaActions
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
|
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.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
|
||||||
|
|
||||||
class MediaViewerPresenter @AssistedInject constructor(
|
class MediaViewerPresenter @AssistedInject constructor(
|
||||||
@Assisted private val inputs: MediaViewerNode.Inputs,
|
@Assisted private val inputs: MediaViewerNode.Inputs,
|
||||||
private val localMediaFactory: LocalMediaFactory,
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
private val mediaLoader: MatrixMediaLoader,
|
private val mediaLoader: MatrixMediaLoader,
|
||||||
|
private val localMediaActions: LocalMediaActions,
|
||||||
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : Presenter<MediaViewerState> {
|
) : Presenter<MediaViewerState> {
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -57,6 +66,8 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||||
val localMedia: MutableState<Async<LocalMedia>> = remember {
|
val localMedia: MutableState<Async<LocalMedia>> = remember {
|
||||||
mutableStateOf(Async.Uninitialized)
|
mutableStateOf(Async.Uninitialized)
|
||||||
}
|
}
|
||||||
|
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
|
||||||
|
localMediaActions.Configure()
|
||||||
DisposableEffect(loadMediaTrigger) {
|
DisposableEffect(loadMediaTrigger) {
|
||||||
coroutineScope.downloadMedia(mediaFile, localMedia)
|
coroutineScope.downloadMedia(mediaFile, localMedia)
|
||||||
onDispose {
|
onDispose {
|
||||||
|
|
@ -68,29 +79,84 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||||
when (mediaViewerEvents) {
|
when (mediaViewerEvents) {
|
||||||
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.Share -> coroutineScope.share(localMedia.value)
|
||||||
|
MediaViewerEvents.OpenWith -> coroutineScope.open(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,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
|
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
|
||||||
localMedia.value = Async.Loading()
|
localMedia.value = Async.Loading()
|
||||||
mediaLoader.downloadMediaFile(inputs.mediaSource, inputs.mimeType)
|
mediaLoader.downloadMediaFile(
|
||||||
|
source = inputs.mediaSource,
|
||||||
|
mimeType = inputs.mediaInfo.mimeType,
|
||||||
|
body = inputs.mediaInfo.name
|
||||||
|
)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
mediaFile.value = it
|
mediaFile.value = it
|
||||||
}.mapCatching {
|
}.mapCatching { mediaFile ->
|
||||||
localMediaFactory.createFromMediaFile(it, inputs.mimeType)
|
localMediaFactory.createFromMediaFile(
|
||||||
|
mediaFile = mediaFile,
|
||||||
|
mediaInfo = inputs.mediaInfo
|
||||||
|
)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
localMedia.value = Async.Success(it)
|
localMedia.value = Async.Success(it)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
localMedia.value = Async.Failure(it)
|
localMedia.value = Async.Failure(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
|
||||||
|
if (localMedia is Async.Success) {
|
||||||
|
localMediaActions.saveOnDisk(localMedia.state)
|
||||||
|
.onSuccess {
|
||||||
|
val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android)
|
||||||
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||||
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
|
}
|
||||||
|
} else Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
|
||||||
|
if (localMedia is Async.Success) {
|
||||||
|
localMediaActions.share(localMedia.state)
|
||||||
|
.onFailure {
|
||||||
|
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||||
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
|
}
|
||||||
|
} else Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
|
||||||
|
if (localMedia is Async.Success) {
|
||||||
|
localMediaActions.open(localMedia.state)
|
||||||
|
.onFailure {
|
||||||
|
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||||
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
|
}
|
||||||
|
} else Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mediaActionsError(throwable: Throwable): Int {
|
||||||
|
return if (throwable is ActivityNotFoundException) {
|
||||||
|
UtilsR.string.error_no_compatible_app_found
|
||||||
|
} else {
|
||||||
|
StringR.string.error_unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,15 @@
|
||||||
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.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 eventSink: (MediaViewerEvents) -> Unit,
|
val eventSink: (MediaViewerEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,41 @@ 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.Loading(),
|
||||||
|
aFileInfo(),
|
||||||
|
),
|
||||||
|
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
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.viewer
|
package io.element.android.features.messages.impl.media.viewer
|
||||||
|
|
||||||
|
|
@ -21,8 +22,21 @@ import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.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.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Snackbar
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -32,18 +46,25 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
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.LocalMedia
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
||||||
|
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.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.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.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -52,6 +73,7 @@ import io.element.android.libraries.ui.strings.R as StringR
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaViewerView(
|
fun MediaViewerView(
|
||||||
state: MediaViewerState,
|
state: MediaViewerState,
|
||||||
|
onBackPressed: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
@ -63,61 +85,132 @@ fun MediaViewerView(
|
||||||
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
||||||
}
|
}
|
||||||
|
|
||||||
var showProgress by remember {
|
val localMediaViewState = rememberLocalMediaViewState()
|
||||||
mutableStateOf(false)
|
val showThumbnail = !localMediaViewState.isReady
|
||||||
}
|
val showProgress = rememberShowProgress(state.downloadedMedia)
|
||||||
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
|
|
||||||
// Trick to avoid showing progress indicator if the media is already on disk.
|
Scaffold(
|
||||||
// When sdk will expose download progress we'll be able to remove this.
|
modifier,
|
||||||
LaunchedEffect(state.downloadedMedia) {
|
topBar = {
|
||||||
showProgress = false
|
MediaViewerTopBar(
|
||||||
delay(100)
|
actionsEnabled = state.downloadedMedia is Async.Success,
|
||||||
if (state.downloadedMedia.isLoading()) {
|
onBackPressed = onBackPressed,
|
||||||
showProgress = true
|
eventSink = state.eventSink
|
||||||
}
|
)
|
||||||
}
|
},
|
||||||
|
snackbarHost = {
|
||||||
var showThumbnail by remember {
|
SnackbarHost(snackbarHostState) { data ->
|
||||||
mutableStateOf(true)
|
Snackbar(
|
||||||
}
|
snackbarData = data,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
fun onMediaReady() {
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
showThumbnail = false
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
Scaffold(modifier) {
|
) {
|
||||||
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(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
|
localMedia = state.downloadedMedia.dataOrNull(),
|
||||||
|
mediaInfo = state.mediaInfo,
|
||||||
|
)
|
||||||
|
ThumbnailView(
|
||||||
|
thumbnailSource = state.thumbnailSource,
|
||||||
|
showThumbnail = showThumbnail,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
LocalMediaView(
|
|
||||||
localMedia = state.downloadedMedia.dataOrNull(),
|
|
||||||
mimeType = state.mimeType,
|
|
||||||
onReady = ::onMediaReady
|
|
||||||
)
|
|
||||||
ThumbnailView(
|
|
||||||
thumbnailSource = state.thumbnailSource,
|
|
||||||
showThumbnail = showThumbnail,
|
|
||||||
showProgress = showProgress,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberShowProgress(downloadedMedia: Async<LocalMedia>): Boolean {
|
||||||
|
var showProgress by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
showProgress = downloadedMedia.isLoading()
|
||||||
|
} else {
|
||||||
|
// Trick to avoid showing progress indicator if the media is already on disk.
|
||||||
|
// When sdk will expose download progress we'll be able to remove this.
|
||||||
|
LaunchedEffect(downloadedMedia) {
|
||||||
|
showProgress = false
|
||||||
|
delay(100)
|
||||||
|
if (downloadedMedia.isLoading()) {
|
||||||
|
showProgress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return showProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaViewerTopBar(
|
||||||
|
actionsEnabled: Boolean,
|
||||||
|
onBackPressed: () -> Unit,
|
||||||
|
eventSink: (MediaViewerEvents) -> Unit,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {},
|
||||||
|
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
enabled = actionsEnabled,
|
||||||
|
onClick = {
|
||||||
|
eventSink(MediaViewerEvents.OpenWith)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = StringR.string.action_open_with))
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
enabled = actionsEnabled,
|
||||||
|
onClick = {
|
||||||
|
eventSink(MediaViewerEvents.SaveOnDisk)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = StringR.string.action_save))
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
enabled = actionsEnabled,
|
||||||
|
onClick = {
|
||||||
|
eventSink(MediaViewerEvents.Share)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = StringR.string.action_share))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThumbnailView(
|
private fun ThumbnailView(
|
||||||
thumbnailSource: MediaSource?,
|
thumbnailSource: MediaSource?,
|
||||||
showThumbnail: Boolean,
|
showThumbnail: Boolean,
|
||||||
showProgress: Boolean,
|
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showThumbnail,
|
visible = showThumbnail,
|
||||||
|
|
@ -139,14 +232,6 @@ private fun ThumbnailView(
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
if (showProgress) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.roundedBackground(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,5 +260,6 @@ fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class
|
||||||
private fun ContentToPreview(state: MediaViewerState) {
|
private fun ContentToPreview(state: MediaViewerState) {
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
|
onBackPressed = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Attachment
|
import androidx.compose.material.icons.outlined.Attachment
|
||||||
|
|
@ -61,10 +62,13 @@ fun TimelineItemFileView(
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Attachment,
|
imageVector = Icons.Outlined.Attachment,
|
||||||
contentDescription = "OpenFile",
|
contentDescription = "OpenFile",
|
||||||
modifier = Modifier.size(16.dp).rotate(-45f),
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.rotate(-45f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp),) {
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = content.body,
|
text = content.body,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
|
@ -74,6 +78,9 @@ fun TimelineItemFileView(
|
||||||
Text(
|
Text(
|
||||||
text = content.fileExtensionAndSize,
|
text = content.fileExtensionAndSize,
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,6 @@
|
||||||
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
|
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
|
||||||
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
||||||
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string>
|
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string>
|
||||||
|
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
||||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -92,7 +92,6 @@ class AttachmentsPreviewPresenterTest {
|
||||||
private fun anAttachmentsPreviewPresenter(
|
private fun anAttachmentsPreviewPresenter(
|
||||||
localMedia: LocalMedia = aLocalMedia(
|
localMedia: LocalMedia = aLocalMedia(
|
||||||
uri = mockMediaUrl,
|
uri = mockMediaUrl,
|
||||||
mimeType = MimeTypes.IMAGE_JPEG
|
|
||||||
),
|
),
|
||||||
room: MatrixRoom = FakeMatrixRoom()
|
room: MatrixRoom = FakeMatrixRoom()
|
||||||
): AttachmentsPreviewPresenter {
|
): AttachmentsPreviewPresenter {
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,18 @@
|
||||||
package io.element.android.features.messages.fixtures
|
package io.element.android.features.messages.fixtures
|
||||||
|
|
||||||
import android.net.Uri
|
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.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
|
||||||
|
import io.element.android.features.messages.impl.media.local.anImageInfo
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
|
||||||
fun aLocalMedia(
|
fun aLocalMedia(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String = MimeTypes.IMAGE_JPEG,
|
mediaInfo: MediaInfo = anImageInfo(),
|
||||||
name: String = "a media",
|
|
||||||
size: Long = 1000,
|
|
||||||
) = LocalMedia(
|
) = LocalMedia(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
info = mediaInfo
|
||||||
name = name,
|
|
||||||
size = size,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.media
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMediaActions
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions {
|
||||||
|
|
||||||
|
var shouldFail = false
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Configure() {
|
||||||
|
//NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
if (shouldFail) {
|
||||||
|
Result.failure(RuntimeException())
|
||||||
|
} else {
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
if (shouldFail) {
|
||||||
|
Result.failure(RuntimeException())
|
||||||
|
} else {
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun open(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
|
if (shouldFail) {
|
||||||
|
Result.failure(RuntimeException())
|
||||||
|
} else {
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,18 +20,26 @@ import android.net.Uri
|
||||||
import io.element.android.features.messages.fixtures.aLocalMedia
|
import io.element.android.features.messages.fixtures.aLocalMedia
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMedia
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
||||||
|
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
|
||||||
class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory {
|
class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory {
|
||||||
|
|
||||||
var fallbackMimeType: String = MimeTypes.OctetStream
|
var fallbackMimeType: String = MimeTypes.OctetStream
|
||||||
|
var fallbackName: String = "File name"
|
||||||
|
var fallbackFileSize = "0B"
|
||||||
|
|
||||||
override fun createFromMediaFile(mediaFile: MediaFile, mimeType: String?): LocalMedia {
|
override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia {
|
||||||
return aLocalMedia(uri = localMediaUri, mimeType = mimeType ?: fallbackMimeType)
|
return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createFromUri(uri: Uri, mimeType: String?): LocalMedia {
|
override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia {
|
||||||
return aLocalMedia(uri, mimeType ?: fallbackMimeType)
|
val mediaInfo = MediaInfo(
|
||||||
|
name = name ?: fallbackName,
|
||||||
|
mimeType = mimeType ?: fallbackMimeType,
|
||||||
|
formattedFileSize = formattedFileSize ?: fallbackFileSize
|
||||||
|
)
|
||||||
|
return aLocalMedia(uri, mediaInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,65 +19,122 @@
|
||||||
package io.element.android.features.messages.media.viewer
|
package io.element.android.features.messages.media.viewer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.media3.common.MimeTypes
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.media.local.MediaInfo
|
||||||
import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents
|
||||||
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.media.viewer.MediaViewerPresenter
|
import io.element.android.features.messages.impl.media.viewer.MediaViewerPresenter
|
||||||
|
import io.element.android.features.messages.media.FakeLocalMediaActions
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
||||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
private const val TESTED_MIME_TYPE = MimeTypes.IMAGE_JPEG
|
private val TESTED_MEDIA_INFO = MediaInfo(
|
||||||
private const val TESTED_MEDIA_NAME = "MediaName"
|
name = "",
|
||||||
|
mimeType = "",
|
||||||
|
formattedFileSize = ""
|
||||||
|
)
|
||||||
|
|
||||||
class MediaViewerPresenterTest {
|
class MediaViewerPresenterTest {
|
||||||
|
|
||||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
private val mockMediaUri: Uri = mockk("localMediaUri")
|
||||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
|
||||||
private val mediaLoader = FakeMediaLoader()
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - download media success scenario`() = runTest {
|
fun `present - download media success scenario`() = runTest {
|
||||||
val presenter = aMediaViewerPresenter()
|
val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
|
||||||
|
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
|
||||||
|
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
|
||||||
|
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
var state = awaitItem()
|
||||||
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
|
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||||
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME)
|
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
||||||
val loadingState = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||||
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1)
|
state = awaitItem()
|
||||||
val successState = awaitItem()
|
val successData = state.downloadedMedia.dataOrNull()
|
||||||
val successData = successState.downloadedMedia.dataOrNull()
|
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||||
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
|
||||||
assertThat(successData).isNotNull()
|
assertThat(successData).isNotNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - check all actions `() = runTest {
|
||||||
|
val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
|
||||||
|
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
|
||||||
|
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
|
||||||
|
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
var state = awaitItem()
|
||||||
|
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||||
|
// no state changes while media is loading
|
||||||
|
state.eventSink(MediaViewerEvents.OpenWith)
|
||||||
|
state.eventSink(MediaViewerEvents.Share)
|
||||||
|
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||||
|
// Should succeed without change of state
|
||||||
|
state.eventSink(MediaViewerEvents.OpenWith)
|
||||||
|
// Should succeed without change of state
|
||||||
|
state.eventSink(MediaViewerEvents.Share)
|
||||||
|
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNull()
|
||||||
|
|
||||||
|
// Check failures
|
||||||
|
mediaActions.shouldFail = true
|
||||||
|
state.eventSink(MediaViewerEvents.OpenWith)
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNull()
|
||||||
|
state.eventSink(MediaViewerEvents.Share)
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNull()
|
||||||
|
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNotNull()
|
||||||
|
state = awaitItem()
|
||||||
|
assertThat(state.snackbarMessage).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - download media failure then retry with success scenario`() = runTest {
|
fun `present - download media failure then retry with success scenario`() = runTest {
|
||||||
val presenter = aMediaViewerPresenter()
|
val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
|
||||||
|
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
|
||||||
|
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
|
||||||
|
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
mediaLoader.shouldFail = true
|
mediaLoader.shouldFail = true
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
|
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||||
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME)
|
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
||||||
val loadingState = awaitItem()
|
val loadingState = awaitItem()
|
||||||
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||||
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
|
|
||||||
val failureState = awaitItem()
|
val failureState = awaitItem()
|
||||||
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
|
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
|
||||||
mediaLoader.shouldFail = false
|
mediaLoader.shouldFail = false
|
||||||
|
|
@ -86,7 +143,6 @@ class MediaViewerPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val retryLoadingState = awaitItem()
|
val retryLoadingState = awaitItem()
|
||||||
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||||
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
|
|
||||||
val successState = awaitItem()
|
val successState = awaitItem()
|
||||||
val successData = successState.downloadedMedia.dataOrNull()
|
val successData = successState.downloadedMedia.dataOrNull()
|
||||||
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||||
|
|
@ -94,16 +150,20 @@ class MediaViewerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter {
|
private fun aMediaViewerPresenter(
|
||||||
|
mediaLoader: FakeMediaLoader,
|
||||||
|
localMediaActions: FakeLocalMediaActions,
|
||||||
|
): MediaViewerPresenter {
|
||||||
return MediaViewerPresenter(
|
return MediaViewerPresenter(
|
||||||
inputs = MediaViewerNode.Inputs(
|
inputs = MediaViewerNode.Inputs(
|
||||||
name = TESTED_MEDIA_NAME,
|
mediaInfo = TESTED_MEDIA_INFO,
|
||||||
mediaSource = aMediaSource(),
|
mediaSource = aMediaSource(),
|
||||||
mimeType = mimeType,
|
|
||||||
thumbnailSource = null
|
thumbnailSource = null
|
||||||
),
|
),
|
||||||
localMediaFactory = localMediaFactory,
|
localMediaFactory = localMediaFactory,
|
||||||
mediaLoader = mediaLoader
|
mediaLoader = mediaLoader,
|
||||||
|
localMediaActions = localMediaActions,
|
||||||
|
snackbarDispatcher = SnackbarDispatcher()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,17 +41,13 @@ import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Snackbar
|
import androidx.compose.material3.Snackbar
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
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.clip
|
||||||
|
|
@ -85,8 +81,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.noFontPadding
|
import io.element.android.libraries.designsystem.theme.noFontPadding
|
||||||
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
|
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
|
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import io.element.android.libraries.designsystem.R as DrawableR
|
import io.element.android.libraries.designsystem.R as DrawableR
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
|
@ -184,21 +180,7 @@ fun RoomListContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
val snackbarMessageText = if (state.snackbarMessage != null) {
|
|
||||||
stringResource(state.snackbarMessage.messageResId)
|
|
||||||
} else null
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
if (snackbarMessageText != null) {
|
|
||||||
SideEffect {
|
|
||||||
coroutineScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(
|
|
||||||
message = snackbarMessageText,
|
|
||||||
duration = SnackbarDuration.Short,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,24 @@ import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import java.io.File
|
import androidx.core.net.toFile
|
||||||
|
|
||||||
|
fun Context.getMimeType(uri: Uri): String? = when (uri.scheme) {
|
||||||
|
ContentResolver.SCHEME_CONTENT -> contentResolver.getType(uri)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
|
fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
|
||||||
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
|
||||||
else -> uri.path?.let(::File)?.name
|
ContentResolver.SCHEME_FILE -> uri.toFile().name
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.getFileSize(uri: Uri): Long {
|
fun Context.getFileSize(uri: Uri): Long {
|
||||||
return when (uri.scheme) {
|
return when (uri.scheme) {
|
||||||
ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri)
|
ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri)
|
||||||
else -> uri.path?.let(::File)?.length()
|
ContentResolver.SCHEME_FILE -> uri.toFile().length()
|
||||||
|
else -> 0
|
||||||
} ?: 0
|
} ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ fun Text(
|
||||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
overflow: TextOverflow = TextOverflow.Clip,
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
softWrap: Boolean = true,
|
softWrap: Boolean = true,
|
||||||
|
minLines: Int = 1,
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
style: TextStyle = LocalTextStyle.current
|
style: TextStyle = LocalTextStyle.current
|
||||||
|
|
@ -84,6 +85,7 @@ fun Text(
|
||||||
lineHeight = lineHeight,
|
lineHeight = lineHeight,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
softWrap = softWrap,
|
softWrap = softWrap,
|
||||||
|
minLines = minLines,
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
onTextLayout = onTextLayout,
|
onTextLayout = onTextLayout,
|
||||||
style = style,
|
style = style,
|
||||||
|
|
@ -105,6 +107,7 @@ fun Text(
|
||||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
overflow: TextOverflow = TextOverflow.Clip,
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
softWrap: Boolean = true,
|
softWrap: Boolean = true,
|
||||||
|
minLines: Int = 1,
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
|
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
|
||||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
|
|
@ -124,6 +127,7 @@ fun Text(
|
||||||
lineHeight = lineHeight,
|
lineHeight = lineHeight,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
softWrap = softWrap,
|
softWrap = softWrap,
|
||||||
|
minLines = minLines,
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
inlineContent = inlineContent,
|
inlineContent = inlineContent,
|
||||||
onTextLayout = onTextLayout,
|
onTextLayout = onTextLayout,
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,14 @@ package io.element.android.libraries.designsystem.utils
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import kotlinx.coroutines.Dispatchers
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
@ -56,7 +59,7 @@ fun handleSnackbarMessage(
|
||||||
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
|
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
|
||||||
LaunchedEffect(snackbarMessage) {
|
LaunchedEffect(snackbarMessage) {
|
||||||
if (snackbarMessage != null) {
|
if (snackbarMessage != null) {
|
||||||
launch(Dispatchers.Main) {
|
launch {
|
||||||
snackbarDispatcher.clear()
|
snackbarDispatcher.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +67,25 @@ fun handleSnackbarMessage(
|
||||||
return snackbarMessage
|
return snackbarMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val snackbarMessageText = snackbarMessage?.let {
|
||||||
|
stringResource(id = snackbarMessage.messageResId)
|
||||||
|
}
|
||||||
|
LaunchedEffect(snackbarMessage) {
|
||||||
|
if (snackbarMessageText == null) return@LaunchedEffect
|
||||||
|
coroutineScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = snackbarMessageText,
|
||||||
|
duration = snackbarMessage.duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snackbarHostState
|
||||||
|
}
|
||||||
|
|
||||||
data class SnackbarMessage(
|
data class SnackbarMessage(
|
||||||
@StringRes val messageResId: Int,
|
@StringRes val messageResId: Int,
|
||||||
val duration: SnackbarDuration = SnackbarDuration.Short,
|
val duration: SnackbarDuration = SnackbarDuration.Short,
|
||||||
|
|
@ -33,8 +33,9 @@ interface MatrixMediaLoader {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param source to fetch the data for.
|
* @param source to fetch the data for.
|
||||||
* @param mimeType: optional mime type
|
* @param mimeType: optional mime type.
|
||||||
|
* @param body: optional body which will be used to name the file.
|
||||||
* @return a [Result] of [MediaFile]
|
* @return a [Result] of [MediaFile]
|
||||||
*/
|
*/
|
||||||
suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile>
|
suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.libraries.matrix.api.media
|
package io.element.android.libraries.matrix.api.media
|
||||||
|
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper around a media file on the disk.
|
* A wrapper around a media file on the disk.
|
||||||
|
|
@ -25,3 +26,7 @@ import java.io.Closeable
|
||||||
interface MediaFile : Closeable {
|
interface MediaFile : Closeable {
|
||||||
fun path(): String
|
fun path(): String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MediaFile.toFile(): File {
|
||||||
|
return File(path())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ class RustMatrixClient constructor(
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val baseDirectory: File,
|
private val baseDirectory: File,
|
||||||
|
private val baseCacheDirectory: File,
|
||||||
private val clock: SystemClock,
|
private val clock: SystemClock,
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
|
|
||||||
|
|
@ -188,7 +189,7 @@ class RustMatrixClient constructor(
|
||||||
override val invitesDataSource: RoomSummaryDataSource
|
override val invitesDataSource: RoomSummaryDataSource
|
||||||
get() = rustInvitesDataSource
|
get() = rustInvitesDataSource
|
||||||
|
|
||||||
private val rustMediaLoader = RustMediaLoader(dispatchers, client)
|
private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client)
|
||||||
override val mediaLoader: MatrixMediaLoader
|
override val mediaLoader: MatrixMediaLoader
|
||||||
get() = rustMediaLoader
|
get() = rustMediaLoader
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.auth
|
package io.element.android.libraries.matrix.impl.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.extensions.mapFailure
|
import io.element.android.libraries.core.extensions.mapFailure
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
|
@ -48,6 +50,7 @@ import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthentication
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
class RustMatrixAuthenticationService @Inject constructor(
|
class RustMatrixAuthenticationService @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val baseDirectory: File,
|
private val baseDirectory: File,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
|
@ -179,6 +182,7 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
baseDirectory = baseDirectory,
|
baseDirectory = baseDirectory,
|
||||||
|
baseCacheDirectory = context.cacheDir,
|
||||||
clock = clock,
|
clock = clock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,21 @@ import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
|
import java.io.File
|
||||||
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
|
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
|
||||||
|
|
||||||
class RustMediaLoader(
|
class RustMediaLoader(
|
||||||
|
baseCacheDirectory: File,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val innerClient: Client
|
private val innerClient: Client,
|
||||||
) : MatrixMediaLoader {
|
) : MatrixMediaLoader {
|
||||||
|
|
||||||
|
private val cacheDirectory = File(baseCacheDirectory, "temp/media").apply {
|
||||||
|
if (!exists()) {
|
||||||
|
mkdirs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
|
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
|
||||||
withContext(dispatchers.io) {
|
withContext(dispatchers.io) {
|
||||||
|
|
@ -59,14 +67,16 @@ class RustMediaLoader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> =
|
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> =
|
||||||
withContext(dispatchers.io) {
|
withContext(dispatchers.io) {
|
||||||
runCatching {
|
runCatching {
|
||||||
source.toRustMediaSource().use { mediaSource ->
|
source.toRustMediaSource().use { mediaSource ->
|
||||||
val mediaFile = innerClient.getMediaFile(
|
val mediaFile = innerClient.getMediaFile(
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
body = null,
|
body = body,
|
||||||
mimeType = mimeType ?: "application/octet-stream"
|
mimeType = mimeType ?: "application/octet-stream",
|
||||||
|
//TODO uncomment when rust api will be merged
|
||||||
|
//tempDir = cacheDirectory.path,
|
||||||
)
|
)
|
||||||
RustMediaFile(mediaFile)
|
RustMediaFile(mediaFile)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,6 @@ dependencies {
|
||||||
api(projects.libraries.core)
|
api(projects.libraries.core)
|
||||||
api(projects.libraries.matrix.api)
|
api(projects.libraries.matrix.api)
|
||||||
api(libs.coroutines.core)
|
api(libs.coroutines.core)
|
||||||
|
implementation(libs.coroutines.test)
|
||||||
|
implementation(projects.tests.testutils)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.test
|
package io.element.android.libraries.matrix.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
@ -36,15 +37,18 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
|
||||||
class FakeMatrixClient(
|
class FakeMatrixClient(
|
||||||
override val sessionId: SessionId = A_SESSION_ID,
|
override val sessionId: SessionId = A_SESSION_ID,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
|
private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
|
||||||
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
|
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
|
||||||
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||||
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||||
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
|
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers),
|
||||||
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
||||||
private val pushersService: FakePushersService = FakePushersService(),
|
private val pushersService: FakePushersService = FakePushersService(),
|
||||||
private val notificationService: FakeNotificationService = FakeNotificationService(),
|
private val notificationService: FakeNotificationService = FakeNotificationService(),
|
||||||
|
|
|
||||||
|
|
@ -16,40 +16,40 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.test.media
|
package io.element.android.libraries.matrix.test.media
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
class FakeMediaLoader : MatrixMediaLoader {
|
class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader {
|
||||||
|
|
||||||
var shouldFail = false
|
var shouldFail = false
|
||||||
|
|
||||||
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> {
|
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = withContext(coroutineDispatchers.io){
|
||||||
delay(FAKE_DELAY_IN_MS)
|
if (shouldFail) {
|
||||||
return if (shouldFail) {
|
|
||||||
Result.failure(RuntimeException())
|
Result.failure(RuntimeException())
|
||||||
} else {
|
} else {
|
||||||
return Result.success(ByteArray(0))
|
Result.success(ByteArray(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> {
|
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> = withContext(coroutineDispatchers.io){
|
||||||
delay(FAKE_DELAY_IN_MS)
|
if (shouldFail) {
|
||||||
return if (shouldFail) {
|
|
||||||
Result.failure(RuntimeException())
|
Result.failure(RuntimeException())
|
||||||
} else {
|
} else {
|
||||||
return Result.success(ByteArray(0))
|
Result.success(ByteArray(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?): Result<MediaFile> {
|
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> = withContext(coroutineDispatchers.io){
|
||||||
delay(FAKE_DELAY_IN_MS)
|
if (shouldFail) {
|
||||||
return if (shouldFail) {
|
|
||||||
Result.failure(RuntimeException())
|
Result.failure(RuntimeException())
|
||||||
} else {
|
} else {
|
||||||
return Result.success(FakeMediaFile(""))
|
Result.success(FakeMediaFile(""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
|
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
|
||||||
<string name="notification_invitation_action_join">"Join"</string>
|
<string name="notification_invitation_action_join">"Join"</string>
|
||||||
<string name="notification_invitation_action_reject">"Reject"</string>
|
<string name="notification_invitation_action_reject">"Reject"</string>
|
||||||
|
<string name="notification_invite_body">"invited you"</string>
|
||||||
<string name="notification_new_messages">"New Messages"</string>
|
<string name="notification_new_messages">"New Messages"</string>
|
||||||
<string name="notification_room_action_mark_as_read">"Mark as read"</string>
|
<string name="notification_room_action_mark_as_read">"Mark as read"</string>
|
||||||
<string name="notification_sender_me">"Me"</string>
|
<string name="notification_sender_me">"Me"</string>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
<string name="action_no">"No"</string>
|
<string name="action_no">"No"</string>
|
||||||
<string name="action_not_now">"Not now"</string>
|
<string name="action_not_now">"Not now"</string>
|
||||||
<string name="action_ok">"OK"</string>
|
<string name="action_ok">"OK"</string>
|
||||||
|
<string name="action_open_with">"Open with"</string>
|
||||||
<string name="action_quick_reply">"Quick reply"</string>
|
<string name="action_quick_reply">"Quick reply"</string>
|
||||||
<string name="action_quote">"Quote"</string>
|
<string name="action_quote">"Quote"</string>
|
||||||
<string name="action_remove">"Remove"</string>
|
<string name="action_remove">"Remove"</string>
|
||||||
|
|
@ -145,7 +146,20 @@
|
||||||
<string name="room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
|
<string name="room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
|
||||||
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
|
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
|
||||||
<string name="room_timeline_read_marker_title">"New"</string>
|
<string name="room_timeline_read_marker_title">"New"</string>
|
||||||
|
<string name="screen_account_provider_change">"Change account provider"</string>
|
||||||
|
<string name="screen_account_provider_continue">"Continue"</string>
|
||||||
|
<string name="screen_account_provider_form_hint">"Homeserver address"</string>
|
||||||
|
<string name="screen_account_provider_form_notice">"Enter a search term or a domain address."</string>
|
||||||
|
<string name="screen_account_provider_form_subtitle">"Search for a company, community, or private server."</string>
|
||||||
|
<string name="screen_account_provider_form_title">"Find an account provider"</string>
|
||||||
|
<string name="screen_account_provider_signin_title">"You’re about to sign in to %s"</string>
|
||||||
|
<string name="screen_account_provider_signup_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string>
|
||||||
|
<string name="screen_account_provider_signup_title">"You’re about to create an account on %s"</string>
|
||||||
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
|
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
|
||||||
|
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org is an open network for secure, decentralized communication."</string>
|
||||||
|
<string name="screen_change_account_provider_other">"Other"</string>
|
||||||
|
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
|
||||||
|
<string name="screen_change_account_provider_title">"Change account provider"</string>
|
||||||
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
|
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
|
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
|
||||||
|
|
@ -168,4 +182,4 @@
|
||||||
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
|
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
|
||||||
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
|
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
|
||||||
<string name="screen_report_content_block_user">"Block user"</string>
|
<string name="screen_report_content_block_user">"Block user"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() {
|
||||||
val baseDirectory = File(applicationContext.filesDir, "sessions")
|
val baseDirectory = File(applicationContext.filesDir, "sessions")
|
||||||
|
|
||||||
RustMatrixAuthenticationService(
|
RustMatrixAuthenticationService(
|
||||||
|
context = applicationContext,
|
||||||
baseDirectory = baseDirectory,
|
baseDirectory = baseDirectory,
|
||||||
coroutineScope = Singleton.appScope,
|
coroutineScope = Singleton.appScope,
|
||||||
coroutineDispatchers = Singleton.coroutineDispatchers,
|
coroutineDispatchers = Singleton.coroutineDispatchers,
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,6 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.test.junit)
|
implementation(libs.test.junit)
|
||||||
implementation(libs.test.mockk)
|
|
||||||
implementation(libs.test.truth)
|
|
||||||
implementation(libs.test.turbine)
|
|
||||||
implementation(libs.coroutines.test)
|
implementation(libs.coroutines.test)
|
||||||
implementation(projects.libraries.matrix.test)
|
|
||||||
implementation(projects.services.appnavstate.test)
|
|
||||||
implementation(projects.services.appnavstate.test)
|
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:846f4846bed07b1aa030c07fbde42bdf38f6329250e1641ad83a9bd8786ea683
|
oid sha256:ade6aa1e0fd7731173f2ae3424a931234d64e0aa8334a93c983ee46ff6dc5ce5
|
||||||
size 183396
|
size 17170
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607
|
oid sha256:846f4846bed07b1aa030c07fbde42bdf38f6329250e1641ad83a9bd8786ea683
|
||||||
size 98742
|
size 183396
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607
|
||||||
|
size 98742
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
|
oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e
|
||||||
size 393618
|
size 395351
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
|
oid sha256:dd52b8ee709319d8b4e5fbc33888248975593cab1e45fb9e81b0e0178e529a66
|
||||||
size 393618
|
size 395357
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
|
oid sha256:8d26e9c883bfc2c45ad6a48115fe450df35dd1653644a512166716460cedcc7e
|
||||||
size 393618
|
size 395351
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
|
oid sha256:49d8a19399542ae40d10ca7f07369fa68f1f4fbca52a61ffe0cdfa6d7fe6409a
|
||||||
size 393618
|
size 395361
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
|
oid sha256:c1c1eedbab868e0c2501220293608572850e052f685f7076ec939b3f1a9abf27
|
||||||
size 393618
|
size 4464
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:af5875f111c8763508615860bd04f0feee42190da78dff3968ec5598f112fbdc
|
||||||
|
size 6388
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6c8a175985e271948423d677b0d79e9ae930aa227c417d5c78e567abda40e378
|
||||||
|
size 16210
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:367e2b224070dcc8ceb218b17336cd406b650b66f9c0fff50f8ec6093bb174dd
|
||||||
|
size 16182
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6efe12c845bdac4f297fae51e23302b046434c898d4886da0890020375621632
|
oid sha256:8fd21177b0b12bed1327986f084e1ee53e317751e1a30585d5ac72a1c8f35593
|
||||||
size 10518
|
size 9646
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0d444880e0593058b55baed32e06231619575ce25ac5fb955da88b59a185d70f
|
oid sha256:1cc95f45e3761a7d988ce4e3708fa92d2544e3f652f435c86dd261c9ca6f31f0
|
||||||
size 13029
|
size 12130
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b6ed694e25efd8293996045a36e9da57a3a9a971fbb2c233dc8755885f1981e5
|
oid sha256:5f53fe35923250b86a635b48bf8d4ee5e79df1a7844fdc3db5a847651547e1a4
|
||||||
size 23613
|
size 23189
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0a4ca40778073bdae1fa023751e9bb2de2d0e0dbdd4d08a1e978ab07b1f9c37a
|
oid sha256:ce311b7115be9f4de478fbb84c112a11d59e1dd7da1627774802902a8aa61a28
|
||||||
size 9937
|
size 9176
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:80eda21a7258ecd15c8770c528a5c786a0d68c610e1bc34a45cb13597932dc75
|
oid sha256:463a8a5a05102224eccc552ece41084b1b355682f684c4a063d614a0928d810e
|
||||||
size 12070
|
size 11309
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4a2bfd6994de21f68e162a1cdfe75d0c82f6693ef2fbbaa0dc740a6ee6d2a466
|
oid sha256:8e80b3348008def046f743af8f440b03612a163040f16aac4d0dac41632ff171
|
||||||
size 21193
|
size 20833
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:21426c10fe7c8b13628f2c15bd437ca6a727ed9baa4c85f5ef2a9a0e32e42139
|
oid sha256:d9338c43f771fc2a04c825d40358b01bd4ad3dca2512e3a12408fd2d345e8745
|
||||||
size 62722
|
size 61796
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:62bc8700035bd87c9c7045c1b963e72c1ba3970c8232d8dbc9ba31df892a91af
|
oid sha256:68b16f1231e3f328a30458956f395bcbe77afecee2075b07d88dd326beff282a
|
||||||
size 73999
|
size 73031
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:354e63f30121add1d2431da33645be1fb5a813fb90dd757c381d866904aa232b
|
oid sha256:9a46df297336d5224b7e08a52038999ad31d947ecf980b644f3ac988a69de773
|
||||||
size 62593
|
size 61818
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b51fad2dd74f6e7b59c6df8e262b07523ea920bda514b2eb1442f0e65a1fcebb
|
oid sha256:2c420df941878750c090726a8f9a6a2e6f7fdb2ac85aa63321f9efe77032213f
|
||||||
size 74415
|
size 73234
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue