diff --git a/app/src/main/kotlin/io/element/android/x/di/AppGraph.kt b/app/src/main/kotlin/io/element/android/x/di/AppGraph.kt index 7b5ba11f4c9..24884e308cb 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppGraph.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppGraph.kt @@ -16,6 +16,8 @@ import dev.zacsweers.metro.Multibinds import dev.zacsweers.metro.Provides import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.mediaupload.api.VideoMetadataExtractor +import io.element.android.libraries.mediaupload.impl.DefaultVideoMetadataExtractorFactory import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory import kotlin.reflect.KClass @@ -27,6 +29,11 @@ interface AppGraph : NodeFactoriesBindings { val workerProviders: Map, MetroWorkerFactory.WorkerInstanceFactory<*>> + @Provides + fun providesVideoMetadataExtractorFactory( + @ApplicationContext context: Context + ): VideoMetadataExtractor.Factory = DefaultVideoMetadataExtractorFactory(context) + @DependencyGraph.Factory interface Factory { fun create( diff --git a/features/mediapreview/api/build.gradle.kts b/features/mediapreview/api/build.gradle.kts new file mode 100644 index 00000000000..f2666c2f9ec --- /dev/null +++ b/features/mediapreview/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.mediapreview.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.preferences.api) +} diff --git a/features/mediapreview/api/src/main/kotlin/io/element/android/features/mediapreview/api/MediaPreviewEntryPoint.kt b/features/mediapreview/api/src/main/kotlin/io/element/android/features/mediapreview/api/MediaPreviewEntryPoint.kt new file mode 100644 index 00000000000..04ef28b84ee --- /dev/null +++ b/features/mediapreview/api/src/main/kotlin/io/element/android/features/mediapreview/api/MediaPreviewEntryPoint.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +interface MediaPreviewEntryPoint : FeatureEntryPoint { + data class Params( + val localMedia: LocalMedia, + val config: MediaPreviewConfig = MediaPreviewConfig(), + ) + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onSend( + caption: String?, + optimizeImage: Boolean, + videoPreset: VideoCompressionPreset?, + onComplete: () -> Unit, + ) + fun onCancel() + } +} + +data class MediaPreviewConfig( + val initialCaption: String? = null, + val showProgressDialog: Boolean = false, + val sendMode: SendMode = SendMode.DIRECT, + val timelineMode: io.element.android.libraries.matrix.api.timeline.Timeline.Mode = io.element.android.libraries.matrix.api.timeline.Timeline.Mode.Live, + val inReplyToEventId: io.element.android.libraries.matrix.api.core.EventId? = null, + val joinedRoom: io.element.android.libraries.matrix.api.room.JoinedRoom? = null, +) + +enum class SendMode { + DIRECT, + PREPROCESS, +} diff --git a/features/mediapreview/impl/build.gradle.kts b/features/mediapreview/impl/build.gradle.kts new file mode 100644 index 00000000000..42f714ff3e2 --- /dev/null +++ b/features/mediapreview/impl/build.gradle.kts @@ -0,0 +1,55 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.mediapreview.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.mediapreview.api) + implementation(projects.appconfig) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.mediaupload.impl) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.textcomposer.impl) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.di) + implementation(projects.libraries.uiUtils) + implementation(projects.libraries.featureflag.api) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.timber) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.libraries.featureflag.test) +} diff --git a/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/DefaultMediaPreviewEntryPoint.kt b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/DefaultMediaPreviewEntryPoint.kt new file mode 100644 index 00000000000..04c61e2cb56 --- /dev/null +++ b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/DefaultMediaPreviewEntryPoint.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.mediapreview.api.MediaPreviewEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultMediaPreviewEntryPoint : MediaPreviewEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: MediaPreviewEntryPoint.Params, + callback: MediaPreviewEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + MediaPreviewNode.Inputs( + localMedia = params.localMedia, + config = params.config, + onSend = { caption, optimizeImage, videoPreset, onComplete -> + callback.onSend(caption, optimizeImage, videoPreset, onComplete) + }, + onCancel = { callback.onCancel() }, + ), + callback, + ) + ) + } +} diff --git a/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewEvents.kt b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewEvents.kt new file mode 100644 index 00000000000..05520319698 --- /dev/null +++ b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +sealed interface MediaPreviewEvents { + data object Send : MediaPreviewEvents + data object Cancel : MediaPreviewEvents + data object Retry : MediaPreviewEvents + data object ClearError : MediaPreviewEvents + data class ToggleImageOptimization(val enabled: Boolean) : MediaPreviewEvents + data class SelectVideoQuality(val preset: VideoCompressionPreset) : MediaPreviewEvents + data object ShowVideoQualityDialog : MediaPreviewEvents + data object DismissVideoQualityDialog : MediaPreviewEvents +} diff --git a/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewNode.kt b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewNode.kt new file mode 100644 index 00000000000..c4128285e37 --- /dev/null +++ b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewNode.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.mediapreview.api.MediaPreviewConfig +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +@ContributesNode(SessionScope::class) +@AssistedInject +class MediaPreviewNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: MediaPreviewPresenter.Factory, + private val localMediaRenderer: LocalMediaRenderer, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val localMedia: LocalMedia, + val config: MediaPreviewConfig, + val onSend: (String?, Boolean, VideoCompressionPreset?, () -> Unit) -> Unit, + val onCancel: () -> Unit, + ) : NodeInputs + + private val inputs = inputs() + + private val presenter = presenterFactory.create( + localMedia = inputs.localMedia, + config = inputs.config, + onSendListener = object : MediaPreviewPresenter.OnSendListener { + override fun onSend( + caption: String?, + optimizeImage: Boolean, + videoPreset: VideoCompressionPreset?, + onComplete: () -> Unit, + ) { + inputs.onSend(caption, optimizeImage, videoPreset, onComplete) + } + }, + onCancelListener = object : MediaPreviewPresenter.OnCancelListener { + override fun onCancel() { + inputs.onCancel() + } + }, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + MediaPreviewView( + state = state, + localMediaRenderer = localMediaRenderer, + modifier = modifier, + ) + } +} diff --git a/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewPresenter.kt b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewPresenter.kt new file mode 100644 index 00000000000..eacde99c75f --- /dev/null +++ b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewPresenter.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.mediapreview.api.MediaPreviewConfig +import io.element.android.features.mediapreview.api.SendMode +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.mediaupload.api.VideoMetadataExtractor +import io.element.android.libraries.mediaupload.api.compressorHelper +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.math.roundToLong + +@AssistedInject +class MediaPreviewPresenter( + @Assisted private val localMedia: LocalMedia, + @Assisted private val config: MediaPreviewConfig, + @Assisted private val onSendListener: OnSendListener, + @Assisted private val onCancelListener: OnCancelListener, + private val featureFlagService: FeatureFlagService, + private val maxUploadSizeProvider: MaxUploadSizeProvider, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val mediaSenderRoomFactory: MediaSenderRoomFactory, + private val dispatchers: CoroutineDispatchers, + private val videoMetadataExtractorFactory: VideoMetadataExtractor.Factory, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + localMedia: LocalMedia, + config: MediaPreviewConfig, + onSendListener: OnSendListener, + onCancelListener: OnCancelListener, + ): MediaPreviewPresenter + } + + fun interface OnSendListener { + fun onSend( + caption: String?, + optimizeImage: Boolean, + videoPreset: VideoCompressionPreset?, + onComplete: () -> Unit, + ) + } + + fun interface OnCancelListener { + fun onCancel() + } + + private var mediaSender: io.element.android.libraries.mediaupload.api.MediaSender? = null + + @Composable + override fun present(): MediaPreviewState { + val coroutineScope = rememberCoroutineScope() + val sendMode = config.sendMode + + val sendActionState = remember { + mutableStateOf(SendActionState.Idle) + } + + val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = config.initialCaption, initialFocus = false) + val textEditorState by rememberUpdatedState( + TextEditorState.Markdown(markdownTextEditorState, isRoomEncrypted = null) + ) + + val currentCaption: () -> String = { + (textEditorState as TextEditorState.Markdown).state.text.value().toString() + } + + var preprocessMediaJob by remember { mutableStateOf(null) } + + val mediaMimeType = localMedia.info.mimeType + val isImageFile = mediaMimeType.isMimeTypeImage() + val isVideoFile = mediaMimeType.isMimeTypeVideo() + val selectableMediaQualityEnabled by produceState(initialValue = false) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) + } + val showOptimization = selectableMediaQualityEnabled && (isImageFile || isVideoFile) + + val maxUploadSize by produceState(initialValue = 100L * 1024 * 1024) { + value = maxUploadSizeProvider.getMaxUploadSize().getOrElse { 100L * 1024 * 1024 } + } + + val videoSizeEstimations = if (isVideoFile) { + videoMetadataExtractorFactory.create(localMedia.uri).use { extractor -> + val videoDimensions = extractor.getSize().getOrElse { null } + val duration = extractor.getDuration().getOrElse { null } + + if (videoDimensions != null && duration != null) { + VideoCompressionPreset.entries.map { preset -> + val bitRateAsBytes = preset.compressorHelper().calculateOptimalBitrate(videoDimensions, 30) / 8f + val durationInSeconds = duration.inWholeSeconds.toFloat() + val calculatedSize = (bitRateAsBytes * durationInSeconds * 1.1f).roundToLong() + .coerceAtLeast(1024L) + VideoUploadEstimation( + preset = preset, + sizeInBytes = calculatedSize, + canUpload = calculatedSize <= maxUploadSize + ) + }.toImmutableList() + } else { + emptyList() + } + } + } else { + emptyList() + } + + val defaultOptimizationConfig by produceState(initialValue = MediaOptimizationConfig()) { + value = mediaOptimizationConfigProvider.get() + } + var isImageOptimizationEnabled by remember { mutableStateOf(defaultOptimizationConfig.compressImages) } + var selectedVideoPreset by remember { mutableStateOf(defaultOptimizationConfig.videoCompressionPreset) } + var showVideoQualityDialog by remember { mutableStateOf(false) } + var displayFileTooLargeError by remember { mutableStateOf(false) } + + fun handleEvent(event: MediaPreviewEvents) { + when (event) { + is MediaPreviewEvents.Send -> { + if (sendMode == SendMode.PREPROCESS) { + val caption = currentCaption() + preprocessMediaJob = coroutineScope.launch { + val optimizationConfig = MediaOptimizationConfig( + compressImages = isImageOptimizationEnabled, + videoCompressionPreset = selectedVideoPreset, + ) + + preProcessMedia( + mediaOptimizationConfig = optimizationConfig, + displayProgress = true, + sendActionState = sendActionState, + caption = caption, + ) + } + } else { + val caption = currentCaption() + onSendListener.onSend( + caption = caption, + optimizeImage = isImageOptimizationEnabled, + videoPreset = selectedVideoPreset, + onComplete = { onCancelListener.onCancel() } + ) + } + } + MediaPreviewEvents.Cancel -> { + preprocessMediaJob?.cancel() + mediaSender?.cleanUp() + onCancelListener.onCancel() + } + is MediaPreviewEvents.ToggleImageOptimization -> { + isImageOptimizationEnabled = event.enabled + } + is MediaPreviewEvents.SelectVideoQuality -> { + selectedVideoPreset = event.preset + showVideoQualityDialog = false + } + MediaPreviewEvents.ShowVideoQualityDialog -> { + showVideoQualityDialog = true + } + MediaPreviewEvents.DismissVideoQualityDialog -> { + showVideoQualityDialog = false + } + MediaPreviewEvents.Retry -> { + val caption = currentCaption() + preprocessMediaJob = coroutineScope.launch { + preProcessMedia( + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = isImageOptimizationEnabled, + videoCompressionPreset = selectedVideoPreset, + ), + displayProgress = true, + sendActionState = sendActionState, + caption = caption, + ) + } + } + MediaPreviewEvents.ClearError -> { + sendActionState.value = SendActionState.Idle + } + } + } + + return MediaPreviewState( + localMedia = localMedia, + textEditorState = textEditorState, + sendActionState = sendActionState.value, + showOptimizationOptions = showOptimization, + isImageOptimizationEnabled = isImageOptimizationEnabled, + selectedVideoPreset = selectedVideoPreset, + showVideoQualityDialog = showVideoQualityDialog, + displayFileTooLargeError = displayFileTooLargeError, + videoSizeEstimations = videoSizeEstimations, + maxUploadSize = maxUploadSize, + eventSink = ::handleEvent, + ) + } + + private suspend fun CoroutineScope.preProcessMedia( + mediaOptimizationConfig: MediaOptimizationConfig, + displayProgress: Boolean, + sendActionState: MutableState, + caption: String, + ) { + val sender = mediaSender ?: mediaSenderRoomFactory.create(config.joinedRoom!!).also { mediaSender = it } + sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) + sender.preProcessMedia( + uri = localMedia.uri, + mimeType = localMedia.info.mimeType, + mediaOptimizationConfig = mediaOptimizationConfig, + ).fold( + onSuccess = { mediaUploadInfo -> + Timber.d("Media preprocessing finished, ready to upload") + sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) + launch(dispatchers.io) { + sendPreProcessedMedia( + mediaUploadInfo = mediaUploadInfo, + caption = caption, + optimizeImage = mediaOptimizationConfig.compressImages, + videoPreset = mediaOptimizationConfig.videoCompressionPreset, + sendActionState = sendActionState, + ) + } + }, + onFailure = { error -> + Timber.e(error, "Failed to pre-process media") + if (error is CancellationException) { + throw error + } + sendActionState.value = SendActionState.Failure(error, null) + } + ) + } + + private suspend fun sendPreProcessedMedia( + mediaUploadInfo: io.element.android.libraries.mediaupload.api.MediaUploadInfo, + caption: String?, + optimizeImage: Boolean, + videoPreset: VideoCompressionPreset, + sendActionState: MutableState, + ) { + val sender = mediaSender ?: mediaSenderRoomFactory.create(config.joinedRoom!!).also { mediaSender = it } + runCatchingExceptions { + sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo) + sender.sendPreProcessedMedia( + mediaUploadInfo = mediaUploadInfo, + caption = caption, + formattedCaption = null, + inReplyToEventId = config.inReplyToEventId, + ).getOrThrow() + }.fold( + onSuccess = { + sender.cleanUp() + sendActionState.value = SendActionState.Done + onSendListener.onSend( + caption = caption, + optimizeImage = optimizeImage, + videoPreset = videoPreset, + onComplete = { onCancelListener.onCancel() } + ) + }, + onFailure = { error -> + if (error is CancellationException) { + throw error + } + Timber.e(error, "Failed to send attachment") + sendActionState.value = SendActionState.Failure(error, mediaUploadInfo) + } + ) + } +} diff --git a/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewState.kt b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewState.kt new file mode 100644 index 00000000000..c5ebf1cc039 --- /dev/null +++ b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewState.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.model.TextEditorState + +data class MediaPreviewState( + val localMedia: LocalMedia, + val textEditorState: TextEditorState, + val sendActionState: SendActionState, + val showOptimizationOptions: Boolean, + val isImageOptimizationEnabled: Boolean, + val selectedVideoPreset: VideoCompressionPreset?, + val showVideoQualityDialog: Boolean, + val displayFileTooLargeError: Boolean, + val videoSizeEstimations: List, + val maxUploadSize: Long, + val eventSink: (MediaPreviewEvents) -> Unit, +) + +@Immutable +sealed interface SendActionState { + data object Idle : SendActionState + + @Immutable + sealed interface Sending : SendActionState { + data class Processing(val displayProgress: Boolean) : Sending + data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending + data class Uploading(val mediaInfo: MediaUploadInfo) : Sending + } + + data class Failure(val error: Throwable, val mediaInfo: MediaUploadInfo?) : SendActionState + data object Done : SendActionState + + fun mediaInfo(): MediaUploadInfo? = when (this) { + is Sending.ReadyToUpload -> mediaInfo + is Sending.Uploading -> mediaInfo + is Failure -> mediaInfo + else -> null + } +} + +data class VideoUploadEstimation( + val preset: VideoCompressionPreset, + val sizeInBytes: Long, + val canUpload: Boolean, +) diff --git a/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewStateProvider.kt b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewStateProvider.kt new file mode 100644 index 00000000000..0dc09147bab --- /dev/null +++ b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewStateProvider.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState + +class MediaPreviewStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaPreviewState(), + ) +} + +fun aMediaPreviewState( + localMedia: LocalMedia = LocalMedia( + uri = android.net.Uri.EMPTY, + info = anImageMediaInfo(), + ), + sendActionState: SendActionState = SendActionState.Idle, + showOptimizationOptions: Boolean = false, + isImageOptimizationEnabled: Boolean = true, + selectedVideoPreset: VideoCompressionPreset? = VideoCompressionPreset.STANDARD, + showVideoQualityDialog: Boolean = false, + displayFileTooLargeError: Boolean = false, + videoSizeEstimations: List = emptyList(), + maxUploadSize: Long = 100L * 1024 * 1024, +) = MediaPreviewState( + localMedia = localMedia, + textEditorState = TextEditorState.Markdown( + MarkdownTextEditorState("", initialFocus = false), + isRoomEncrypted = null, + ), + sendActionState = sendActionState, + showOptimizationOptions = showOptimizationOptions, + isImageOptimizationEnabled = isImageOptimizationEnabled, + selectedVideoPreset = selectedVideoPreset, + showVideoQualityDialog = showVideoQualityDialog, + displayFileTooLargeError = displayFileTooLargeError, + videoSizeEstimations = videoSizeEstimations, + maxUploadSize = maxUploadSize, + eventSink = {}, +) diff --git a/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewView.kt b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewView.kt new file mode 100644 index 00000000000..25deb47b493 --- /dev/null +++ b/features/mediapreview/impl/src/main/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewView.kt @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.ProgressDialogType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ListDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Switch +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.TextComposer +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter +import io.element.android.features.mediapreview.impl.R as MediaPreviewR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaPreviewView( + state: MediaPreviewState, + localMediaRenderer: LocalMediaRenderer, + modifier: Modifier = Modifier, +) { + val isUploading = state.sendActionState is SendActionState.Sending.Uploading + val isDone = state.sendActionState is SendActionState.Done + + BackHandler(enabled = !isUploading && !isDone) { + state.eventSink(MediaPreviewEvents.Cancel) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + BackButton( + onClick = { state.eventSink(MediaPreviewEvents.Cancel) }, + ) + }, + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + localMediaRenderer.Render(state.localMedia) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + ) { + if (state.showOptimizationOptions) { + OptimizationOptions( + isImageFile = state.localMedia.info.mimeType.isMimeTypeImage(), + isVideoFile = state.localMedia.info.mimeType.isMimeTypeVideo(), + isImageOptimizationEnabled = state.isImageOptimizationEnabled, + selectedVideoPreset = state.selectedVideoPreset, + videoSizeEstimations = state.videoSizeEstimations, + onToggleImageOptimization = { enabled -> + state.eventSink(MediaPreviewEvents.ToggleImageOptimization(enabled)) + }, + onShowVideoQualityDialog = { + state.eventSink(MediaPreviewEvents.ShowVideoQualityDialog) + }, + ) + } + + MediaPreviewBottomActions( + state = state, + onSendClick = { state.eventSink(MediaPreviewEvents.Send) }, + ) + } + } + } + + SendActionStateDialog( + sendActionState = state.sendActionState, + onRetry = { state.eventSink(MediaPreviewEvents.Retry) }, + onDismiss = { state.eventSink(MediaPreviewEvents.ClearError) }, + ) + + if (state.showVideoQualityDialog) { + VideoQualityDialog( + selectedPreset = state.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, + videoSizeEstimations = state.videoSizeEstimations, + maxFileUploadSize = state.maxUploadSize, + onSubmit = { preset -> + state.eventSink(MediaPreviewEvents.SelectVideoQuality(preset)) + }, + onDismiss = { + state.eventSink(MediaPreviewEvents.DismissVideoQualityDialog) + }, + ) + } +} + +@Composable +private fun SendActionStateDialog( + sendActionState: SendActionState, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + when (sendActionState) { + is SendActionState.Sending.Processing -> { + if (sendActionState.displayProgress) { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(CommonStrings.common_preparing), + showCancelButton = true, + onDismissRequest = onDismiss, + ) + } + } + is SendActionState.Sending.Uploading -> { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(CommonStrings.common_sending), + showCancelButton = true, + onDismissRequest = onDismiss, + ) + } + is SendActionState.Failure -> { + RetryDialog( + content = sendActionState.error.message ?: stringResource(CommonStrings.common_error), + onDismiss = onDismiss, + onRetry = onRetry, + ) + } + else -> Unit + } +} + +@Composable +private fun OptimizationOptions( + isImageFile: Boolean, + isVideoFile: Boolean, + isImageOptimizationEnabled: Boolean, + selectedVideoPreset: VideoCompressionPreset?, + videoSizeEstimations: List, + onToggleImageOptimization: (Boolean) -> Unit, + onShowVideoQualityDialog: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(ElementTheme.colors.bgCanvasDefault) + ) { + if (isImageFile) { + Row( + modifier = Modifier + .fillMaxWidth() + .niceClickable { + onToggleImageOptimization(!isImageOptimizationEnabled) + } + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + text = stringResource(MediaPreviewR.string.screen_media_upload_preview_image_optimization), + style = ElementTheme.typography.fontBodyLgMedium, + ) + Switch( + modifier = Modifier.height(32.dp), + checked = isImageOptimizationEnabled, + onCheckedChange = onToggleImageOptimization, + ) + } + } + + if (isVideoFile) { + val sizeFormatter = rememberFileSizeFormatter() + val estimation = selectedVideoPreset?.let { preset -> + videoSizeEstimations.find { it.preset == preset } + } + val estimationMb = estimation?.sizeInBytes?.let { + if (it > 0) sizeFormatter.format(it, true) else null + } + val qualityTitle = buildString { + append(selectedVideoPreset?.title() ?: stringResource(CommonStrings.common_video_quality_standard)) + if (estimationMb != null) { + append(" ($estimationMb)") + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .niceClickable { onShowVideoQualityDialog() } + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Text( + text = qualityTitle, + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = stringResource(MediaPreviewR.string.screen_media_upload_preview_change_video_quality_prompt), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + } +} + +@Composable +private fun VideoQualityDialog( + selectedPreset: VideoCompressionPreset, + videoSizeEstimations: List, + maxFileUploadSize: Long, + onSubmit: (VideoCompressionPreset) -> Unit, + onDismiss: () -> Unit, +) { + val sizeFormatter = rememberFileSizeFormatter() + var localSelectedPreset by remember(selectedPreset) { mutableStateOf(selectedPreset) } + + val subtitlePartNoFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_no_file_size) + val subtitlePartWithFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_file_size) + val subtitle = remember(maxFileUploadSize) { + buildString { + append(subtitlePartNoFileSize) + append(String.format(subtitlePartWithFileSize, sizeFormatter.format(maxFileUploadSize, true))) + } + } + + ListDialog( + title = stringResource(CommonStrings.dialog_video_quality_selector_title), + subtitle = subtitle, + onSubmit = { onSubmit(localSelectedPreset) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + ) { + for (estimation in videoSizeEstimations) { + val preset = estimation.preset + val isSelected = preset == localSelectedPreset + item( + key = preset, + contentType = preset, + ) { + val estimationMb = sizeFormatter.format(estimation.sizeInBytes, true) + val title = "${preset.title()} ($estimationMb)" + ListItem( + headlineContent = { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + supportingContent = { + Text( + text = preset.subtitle(), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.RadioButton(selected = isSelected), + onClick = { localSelectedPreset = preset }, + enabled = estimation.canUpload, + ) + } + } + } +} + +@Composable +private fun MediaPreviewBottomActions( + state: MediaPreviewState, + onSendClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextComposer( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .imePadding(), + state = state.textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Attachment, + onRequestFocus = {}, + onSendMessage = onSendClick, + showTextFormatting = false, + onResetComposerMode = {}, + onAddAttachment = {}, + onDismissTextFormatting = {}, + onVoiceRecorderEvent = {}, + onVoicePlayerEvent = {}, + onSendVoiceMessage = {}, + onDeleteVoiceMessage = {}, + onReceiveSuggestion = {}, + resolveMentionDisplay = { _, _ -> io.element.android.wysiwyg.display.TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { io.element.android.wysiwyg.display.TextDisplay.Plain }, + onError = {}, + onTyping = {}, + onSelectRichContent = {}, + ) +} + +@Composable +private fun VideoCompressionPreset.title(): String = stringResource( + when (this) { + VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high + VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard + VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low + } +) + +@Composable +private fun VideoCompressionPreset.subtitle(): String = stringResource( + when (this) { + VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high_description + VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard_description + VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low_description + } +) + +@PreviewsDayNight +@Composable +internal fun MediaPreviewViewPreview() = ElementPreview { + val fakeLocalMediaRenderer = object : LocalMediaRenderer { + @Composable + override fun Render(localMedia: LocalMedia) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Media Preview", + color = Color.White, + textAlign = TextAlign.Center + ) + } + } + } + MediaPreviewView( + state = aMediaPreviewState(), + localMediaRenderer = fakeLocalMediaRenderer, + modifier = Modifier + ) +} diff --git a/features/mediapreview/impl/src/main/res/values/temporary.xml b/features/mediapreview/impl/src/main/res/values/temporary.xml new file mode 100644 index 00000000000..552e21d36e6 --- /dev/null +++ b/features/mediapreview/impl/src/main/res/values/temporary.xml @@ -0,0 +1,10 @@ + + + "Optimise image quality" + "Tap to change the video upload quality" + "Optimise image quality" + "Reduce file size before uploading" + "Video quality" + "Select the quality of the video you want to upload." + " The max file size allowed is: %1$s" + \ No newline at end of file diff --git a/features/mediapreview/impl/src/test/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewPresenterTest.kt b/features/mediapreview/impl/src/test/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewPresenterTest.kt new file mode 100644 index 00000000000..38baa07645e --- /dev/null +++ b/features/mediapreview/impl/src/test/kotlin/io/element/android/features/mediapreview/impl/MediaPreviewPresenterTest.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.mediapreview.impl + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.features.mediapreview.api.MediaPreviewConfig +import io.element.android.features.mediapreview.api.SendMode +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaSender +import io.element.android.libraries.mediaupload.test.FakeVideoMetadataExtractorFactory +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class MediaPreviewPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val mockMediaUri: Uri = mockk("localMediaUri") { + every { path } returns "/path/to/media" + } + + @Test + fun `present - initial state`() = runTest { + createMediaPreviewPresenter(localMedia = aLocalMedia(mockMediaUri, anImageMediaInfo())).test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isInstanceOf(SendActionState.Idle::class.java) + assertThat(initialState.showOptimizationOptions).isFalse() + assertThat(initialState.isImageOptimizationEnabled).isTrue() + assertThat(initialState.showVideoQualityDialog).isFalse() + assertThat(initialState.displayFileTooLargeError).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - Cancel invokes onCancelListener`() = runTest { + val onCancelRecorder = lambdaRecorder {} + createMediaPreviewPresenter(onCancelListener = onCancelRecorder).test { + val initialState = awaitItem() + initialState.eventSink(MediaPreviewEvents.Cancel) + cancelAndIgnoreRemainingEvents() + } + onCancelRecorder.assertions().isCalledOnce() + } + + @Test + fun `present - ClearError resets sendActionState`() = runTest { + createMediaPreviewPresenter().test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isInstanceOf(SendActionState.Idle::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - Send in PREPROCESS mode triggers preprocessing`() = runTest { + val preProcessMediaRecorder = lambdaRecorder> { + Result.success(MediaUploadInfo.AnyFile(File("/tmp/test"), mockk())) + } + val mediaSender = FakeMediaSender(preProcessMediaResult = preProcessMediaRecorder) + val joinedRoom = FakeJoinedRoom() + val config = MediaPreviewConfig( + sendMode = SendMode.PREPROCESS, + joinedRoom = joinedRoom, + ) + createMediaPreviewPresenter( + mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, + config = config, + ).test { + val initialState = awaitItem() + initialState.eventSink(MediaPreviewEvents.Send) + cancelAndIgnoreRemainingEvents() + } + preProcessMediaRecorder.assertions().isCalledOnce() + } + + @Test + fun `present - Send in DIRECT mode invokes onSendListener directly`() = runTest { + val onSendRecorder = lambdaRecorder Unit, Unit> { _, _, _, _ -> } + createMediaPreviewPresenter( + onSendListener = MediaPreviewPresenter.OnSendListener(onSendRecorder), + ).test { + val initialState = awaitItem() + initialState.eventSink(MediaPreviewEvents.Send) + cancelAndIgnoreRemainingEvents() + } + onSendRecorder.assertions().isCalledOnce() + } + + @Test + fun `present - Send in PREPROCESS mode failure shows error state`() = runTest { + val failure = RuntimeException("Test error") + val preProcessMediaRecorder = lambdaRecorder> { + Result.failure(failure) + } + val mediaSender = FakeMediaSender(preProcessMediaResult = preProcessMediaRecorder) + val joinedRoom = FakeJoinedRoom() + val config = MediaPreviewConfig( + sendMode = SendMode.PREPROCESS, + joinedRoom = joinedRoom, + ) + createMediaPreviewPresenter( + mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, + config = config, + ).test { + val initialState = awaitItem() + initialState.eventSink(MediaPreviewEvents.Send) + // Just verify that preprocessing was triggered - the test framework + // handles the rest via cancelAndIgnoreRemainingEvents() + cancelAndIgnoreRemainingEvents() + } + // Verify that preProcessMedia was called + preProcessMediaRecorder.assertions().isCalledOnce() + } + + @Test + fun `present - Retry triggers preprocessing with current settings`() = runTest { + val preProcessMediaRecorder = lambdaRecorder> { + Result.success(MediaUploadInfo.AnyFile(File("/tmp/test"), mockk())) + } + val mediaSender = FakeMediaSender(preProcessMediaResult = preProcessMediaRecorder) + val joinedRoom = FakeJoinedRoom() + val config = MediaPreviewConfig( + sendMode = SendMode.PREPROCESS, + joinedRoom = joinedRoom, + ) + createMediaPreviewPresenter( + mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, + config = config, + ).test { + val initialState = awaitItem() + initialState.eventSink(MediaPreviewEvents.Retry) + cancelAndIgnoreRemainingEvents() + } + preProcessMediaRecorder.assertions().isCalledOnce() + } + + @Test + fun `present - with default optimization config`() = runTest { + createMediaPreviewPresenter().test { + val state = awaitItem() + assertThat(state.isImageOptimizationEnabled).isTrue() + assertThat(state.selectedVideoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - non-image non-video files do not show optimization options`() = runTest { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.SelectableMediaQuality.key to true) + ) + val audioMediaInfo = anImageMediaInfo().copy(mimeType = MimeTypes.Mp3) + createMediaPreviewPresenter( + featureFlagService = featureFlagService, + localMedia = aLocalMedia(mockMediaUri, audioMediaInfo), + ).test { + val state = awaitItem() + assertThat(state.showOptimizationOptions).isFalse() + cancelAndIgnoreRemainingEvents() + } + } +} + +internal fun TestScope.createMediaPreviewPresenter( + localMedia: io.element.android.libraries.mediaviewer.api.local.LocalMedia? = null, + config: MediaPreviewConfig = MediaPreviewConfig(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, + videoMetadataExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(), + onSendListener: MediaPreviewPresenter.OnSendListener = MediaPreviewPresenter.OnSendListener { _, _, _, _ -> }, + onCancelListener: MediaPreviewPresenter.OnCancelListener = MediaPreviewPresenter.OnCancelListener {}, +): MediaPreviewPresenter { + val mediaUri: Uri = mockk("mediaUri") { every { path } returns "/path/to/media" } + val media = localMedia ?: aLocalMedia(mediaUri, anImageMediaInfo()) + return MediaPreviewPresenter( + localMedia = media, + config = config, + onSendListener = onSendListener, + onCancelListener = onCancelListener, + featureFlagService = featureFlagService, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + mediaSenderRoomFactory = mediaSenderRoomFactory, + maxUploadSizeProvider = MaxUploadSizeProvider { Result.success(100 * 1024 * 1024L) }, + dispatchers = testCoroutineDispatchers(), + videoMetadataExtractorFactory = videoMetadataExtractorFactory, + ) +} diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 6ff7f7e3223..b37f72ebc6a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -78,6 +78,8 @@ dependencies { implementation(libs.matrix.emojibase.bindings) implementation(projects.features.knockrequests.api) implementation(projects.features.roommembermoderation.api) + implementation(projects.features.mediapreview.api) + implementation(projects.features.mediapreview.impl) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 36e94ec4562..b68bbfd5e42 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -32,9 +32,11 @@ import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationMode +import io.element.android.features.mediapreview.api.MediaPreviewConfig +import io.element.android.features.mediapreview.api.MediaPreviewEntryPoint +import io.element.android.features.mediapreview.api.SendMode import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode import io.element.android.features.messages.impl.report.ReportMessageNode @@ -73,7 +75,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.permalink.PermalinkData -import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.matrix.api.roomlist.RoomListService @@ -83,6 +85,7 @@ import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomNamesCache import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater @@ -94,6 +97,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize +import timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @ContributesNode(RoomScope::class) @@ -108,10 +112,11 @@ class MessagesFlowNode( private val createPollEntryPoint: CreatePollEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint, + private val mediaPreviewEntryPoint: MediaPreviewEntryPoint, private val forwardEntryPoint: ForwardEntryPoint, private val analyticsService: AnalyticsService, private val locationService: LocationService, - private val room: BaseRoom, + private val room: JoinedRoom, private val roomMemberProfilesCache: RoomMemberProfilesCache, private val roomNamesCache: RoomNamesCache, private val mentionSpanUpdater: MentionSpanUpdater, @@ -340,12 +345,47 @@ class MessagesFlowNode( ) } is NavTarget.AttachmentPreview -> { - val inputs = AttachmentsPreviewNode.Inputs( - attachment = navTarget.attachment, - timelineMode = navTarget.timelineMode, - inReplyToEventId = navTarget.inReplyToEventId, + val attachment = navTarget.attachment + val localMedia: LocalMedia? = if (attachment is Attachment.Media) { + attachment.localMedia + } else { + Timber.w("Unhandled attachment type: $attachment") + null + } + if (localMedia == null) { + return@resolve this + } + val previewCallback = object : MediaPreviewEntryPoint.Callback { + override fun onSend( + caption: String?, + optimizeImage: Boolean, + videoPreset: io.element.android.libraries.preferences.api.store.VideoCompressionPreset?, + onComplete: () -> Unit, + ) { + // Handled by MediaPreviewPresenter - it preProcesses and sends automatically for PREPROCESS mode + // Just pop back when complete signal is received + onComplete() + backstack.pop() + } + + override fun onCancel() { + backstack.pop() + } + } + mediaPreviewEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = MediaPreviewEntryPoint.Params( + localMedia = localMedia, + config = MediaPreviewConfig( + sendMode = SendMode.PREPROCESS, + timelineMode = navTarget.timelineMode, + inReplyToEventId = navTarget.inReplyToEventId, + joinedRoom = room, + ), + ), + callback = previewCallback, ) - createNode(buildContext, listOf(inputs)) } is NavTarget.LocationViewer -> { val inputs = ShowLocationEntryPoint.Inputs(navTarget.mode) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt deleted file mode 100644 index d473d4c3f4b..00000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview - -sealed interface AttachmentsPreviewEvent { - data object SendAttachment : AttachmentsPreviewEvent - data object CancelAndDismiss : AttachmentsPreviewEvent - data object CancelAndClearSendState : AttachmentsPreviewEvent -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt deleted file mode 100644 index 451398d7d34..00000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.compound.colors.SemanticColorsLightDark -import io.element.android.compound.theme.ForcedDarkElementTheme -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.libraries.architecture.NodeInputs -import io.element.android.libraries.architecture.inputs -import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer - -@ContributesNode(RoomScope::class) -@AssistedInject -class AttachmentsPreviewNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - presenterFactory: AttachmentsPreviewPresenter.Factory, - private val localMediaRenderer: LocalMediaRenderer, - private val sessionId: SessionId, - private val enterpriseService: EnterpriseService, -) : Node(buildContext, plugins = plugins) { - data class Inputs( - val attachment: Attachment, - val timelineMode: Timeline.Mode, - val inReplyToEventId: EventId?, - ) : NodeInputs - - private val inputs: Inputs = inputs() - - private val onDoneListener = OnDoneListener { - navigateUp() - } - - private val presenter = presenterFactory.create( - attachment = inputs.attachment, - timelineMode = inputs.timelineMode, - onDoneListener = onDoneListener, - inReplyToEventId = inputs.inReplyToEventId, - ) - - @Composable - override fun View(modifier: Modifier) { - val colors by remember { - enterpriseService.semanticColorsFlow(sessionId = sessionId) - }.collectAsState(SemanticColorsLightDark.default) - ForcedDarkElementTheme( - colors = colors, - ) { - val state = presenter.present() - AttachmentsPreviewView( - state = state, - localMediaRenderer = localMediaRenderer, - modifier = modifier - ) - } - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt deleted file mode 100644 index 8e92e53f6a8..00000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ /dev/null @@ -1,340 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter -import io.element.android.libraries.androidutils.file.TemporaryUriDeleter -import io.element.android.libraries.androidutils.file.safeDelete -import io.element.android.libraries.androidutils.hash.hash -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.core.coroutine.firstInstanceOf -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage -import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder -import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.api.MediaSenderFactory -import io.element.android.libraries.mediaupload.api.MediaUploadInfo -import io.element.android.libraries.mediaupload.api.allFiles -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.textcomposer.model.TextEditorState -import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import timber.log.Timber - -@AssistedInject -class AttachmentsPreviewPresenter( - @Assisted private val attachment: Attachment, - @Assisted private val onDoneListener: OnDoneListener, - @Assisted private val timelineMode: Timeline.Mode, - @Assisted private val inReplyToEventId: EventId?, - mediaSenderFactory: MediaSenderFactory, - private val permalinkBuilder: PermalinkBuilder, - private val temporaryUriDeleter: TemporaryUriDeleter, - private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, - @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, -) : Presenter { - @AssistedFactory - interface Factory { - fun create( - attachment: Attachment, - timelineMode: Timeline.Mode, - onDoneListener: OnDoneListener, - inReplyToEventId: EventId?, - ): AttachmentsPreviewPresenter - } - - private val mediaSender = mediaSenderFactory.create(timelineMode) - - @Composable - override fun present(): AttachmentsPreviewState { - val coroutineScope = rememberCoroutineScope() - - val sendActionState = remember { - mutableStateOf(SendActionState.Idle) - } - - val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) - val textEditorState by rememberUpdatedState( - TextEditorState.Markdown(markdownTextEditorState, isRoomEncrypted = null) - ) - - val ongoingSendAttachmentJob = remember { mutableStateOf(null) } - - var preprocessMediaJob by remember { mutableStateOf(null) } - - val mediaAttachment = attachment as Attachment.Media - val mediaOptimizationSelectorPresenter = remember { - mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia) - } - val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present()) - - val observableSendState = snapshotFlow { sendActionState.value } - - var displayFileTooLargeError by remember { mutableStateOf(false) } - - LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) { - // If the media optimization selector is not displayed, we can pre-process the media - // to prepare it for sending. This is done to avoid blocking the UI thread when the - // user clicks on the send button. - if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) { - preprocessMediaJob = preProcessAttachment( - attachment = attachment, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), - displayProgress = false, - sendActionState = sendActionState, - ) - } - } - - val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull() - LaunchedEffect(maxUploadSize) { - // Check file upload size if the media won't be processed for upload - val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage() - val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() - if (maxUploadSize != null && !(isImageFile || isVideoFile)) { - // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. - val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L - if (maxUploadSize < fileSize) { - displayFileTooLargeError = true - } - } - } - - val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() - LaunchedEffect(videoSizeEstimations) { - if (videoSizeEstimations != null) { - // Check if the video size estimations are too large for the max upload size - displayFileTooLargeError = videoSizeEstimations.none { it.canUpload } - } - } - - fun handleEvent(event: AttachmentsPreviewEvent) { - when (event) { - is AttachmentsPreviewEvent.SendAttachment -> { - ongoingSendAttachmentJob.value = coroutineScope.launch { - // If the media optimization selector is displayed, we need to wait for the user to select the options - // before we can pre-process the media. - if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) { - val config = MediaOptimizationConfig( - compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, - videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, - ) - preprocessMediaJob = preProcessAttachment( - attachment = attachment, - mediaOptimizationConfig = config, - displayProgress = true, - sendActionState = sendActionState, - ) - } - - // If the processing was hidden before, make it visible now - if (sendActionState.value is SendActionState.Sending.Processing) { - sendActionState.value = SendActionState.Sending.Processing(displayProgress = true) - } - - // Wait until the media is ready to be uploaded - val mediaUploadInfo = observableSendState.firstInstanceOf().mediaInfo - - // Pre-processing is done, send the attachment - val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) - .takeIf { it.isNotEmpty() } - - // If we're supposed to send the media as a background job, we can dismiss this screen already - if (coroutineContext.isActive) { - onDoneListener() - } - - // Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed - sessionCoroutineScope.launch(dispatchers.io) { - sendPreProcessedMedia( - mediaUploadInfo = mediaUploadInfo, - caption = caption, - sendActionState = sendActionState, - dismissAfterSend = false, - inReplyToEventId = inReplyToEventId, - ) - - // Clean up the pre-processed media after it's been sent - mediaSender.cleanUp() - } - } - } - AttachmentsPreviewEvent.CancelAndDismiss -> { - displayFileTooLargeError = false - - // Cancel media preprocessing and sending - preprocessMediaJob?.cancel() - // If we couldn't send the pre-processed media, remove it - mediaSender.cleanUp() - ongoingSendAttachmentJob.value?.cancel() - - // Dismiss the screen - dismiss( - attachment, - sendActionState, - ) - } - AttachmentsPreviewEvent.CancelAndClearSendState -> { - // Cancel media sending - ongoingSendAttachmentJob.value?.let { - it.cancel() - ongoingSendAttachmentJob.value = null - } - - val mediaUploadInfo = sendActionState.value.mediaUploadInfo() - sendActionState.value = if (mediaUploadInfo != null) { - SendActionState.Sending.ReadyToUpload(mediaUploadInfo) - } else { - SendActionState.Idle - } - } - } - } - - return AttachmentsPreviewState( - attachment = attachment, - sendActionState = sendActionState.value, - textEditorState = textEditorState, - mediaOptimizationSelectorState = mediaOptimizationSelectorState, - displayFileTooLargeError = displayFileTooLargeError, - eventSink = ::handleEvent, - ) - } - - private fun CoroutineScope.preProcessAttachment( - attachment: Attachment, - mediaOptimizationConfig: MediaOptimizationConfig, - displayProgress: Boolean, - sendActionState: MutableState, - ) = launch(dispatchers.io) { - when (attachment) { - is Attachment.Media -> { - preProcessMedia( - mediaAttachment = attachment, - mediaOptimizationConfig = mediaOptimizationConfig, - displayProgress = displayProgress, - sendActionState = sendActionState, - ) - } - } - } - - private suspend fun preProcessMedia( - mediaAttachment: Attachment.Media, - mediaOptimizationConfig: MediaOptimizationConfig, - displayProgress: Boolean, - sendActionState: MutableState, - ) { - sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) - mediaSender.preProcessMedia( - uri = mediaAttachment.localMedia.uri, - mimeType = mediaAttachment.localMedia.info.mimeType, - mediaOptimizationConfig = mediaOptimizationConfig, - ).fold( - onSuccess = { mediaUploadInfo -> - Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload") - sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) - }, - onFailure = { - Timber.e(it, "Failed to pre-process media") - if (it is CancellationException) { - throw it - } else { - sendActionState.value = SendActionState.Failure(it, null) - } - } - ) - } - - private fun dismiss( - attachment: Attachment, - sendActionState: MutableState, - ) { - // Delete the temporary file - when (attachment) { - is Attachment.Media -> { - temporaryUriDeleter.delete(attachment.localMedia.uri) - sendActionState.value.mediaUploadInfo()?.let { data -> - cleanUp(data) - } - } - } - // Reset the sendActionState to ensure that dialog is closed before the screen - sendActionState.value = SendActionState.Done - onDoneListener() - } - - private fun cleanUp( - mediaUploadInfo: MediaUploadInfo, - ) { - mediaUploadInfo.allFiles().forEach { file -> - file.safeDelete() - } - } - - private suspend fun sendPreProcessedMedia( - mediaUploadInfo: MediaUploadInfo, - caption: String?, - sendActionState: MutableState, - dismissAfterSend: Boolean, - inReplyToEventId: EventId?, - ) = runCatchingExceptions { - sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo) - mediaSender.sendPreProcessedMedia( - mediaUploadInfo = mediaUploadInfo, - caption = caption, - formattedCaption = null, - inReplyToEventId = inReplyToEventId, - ).getOrThrow() - }.fold( - onSuccess = { - cleanUp(mediaUploadInfo) - // Reset the sendActionState to ensure that dialog is closed before the screen - sendActionState.value = SendActionState.Done - - if (dismissAfterSend) { - onDoneListener() - } - }, - onFailure = { error -> - Timber.e(error, "Failed to send attachment") - if (error is CancellationException) { - throw error - } else { - sendActionState.value = SendActionState.Failure(error, mediaUploadInfo) - } - } - ) -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt deleted file mode 100644 index 97ca230d775..00000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview - -import androidx.compose.runtime.Immutable -import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState -import io.element.android.libraries.mediaupload.api.MediaUploadInfo -import io.element.android.libraries.textcomposer.model.TextEditorState - -data class AttachmentsPreviewState( - val attachment: Attachment, - val sendActionState: SendActionState, - val textEditorState: TextEditorState, - val mediaOptimizationSelectorState: MediaOptimizationSelectorState, - val displayFileTooLargeError: Boolean, - val eventSink: (AttachmentsPreviewEvent) -> Unit, -) - -@Immutable -sealed interface SendActionState { - data object Idle : SendActionState - - @Immutable - sealed interface Sending : SendActionState { - data class Processing(val displayProgress: Boolean) : Sending - data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending - data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending - } - - data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState - data object Done : SendActionState - - fun mediaUploadInfo(): MediaUploadInfo? = when (this) { - is Sending.ReadyToUpload -> mediaInfo - is Sending.Uploading -> mediaUploadInfo - is Failure -> mediaUploadInfo - else -> null - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt deleted file mode 100644 index 70d7ab006ef..00000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.core.net.toUri -import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState -import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.core.mimetype.MimeTypes -import io.element.android.libraries.matrix.api.media.ImageInfo -import io.element.android.libraries.mediaupload.api.MediaUploadInfo -import io.element.android.libraries.mediaviewer.api.MediaInfo -import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo -import io.element.android.libraries.mediaviewer.api.anImageMediaInfo -import io.element.android.libraries.mediaviewer.api.local.LocalMedia -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.textcomposer.model.TextEditorState -import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import java.io.File - -open class AttachmentsPreviewStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - anAttachmentsPreviewState(), - anAttachmentsPreviewState( - sendActionState = SendActionState.Sending.Processing(displayProgress = false), - textEditorState = aTextEditorStateMarkdown( - initialText = "This is a caption!" - ) - ), - anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)), - anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())), - anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())), - anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())), - anAttachmentsPreviewState(displayFileTooLargeError = true), - anAttachmentsPreviewState( - mediaInfo = aVideoMediaInfo(), - mediaOptimizationSelectorState = aMediaOptimisationSelectorState( - selectedVideoPreset = VideoCompressionPreset.STANDARD, - videoSizeEstimations = aVideoSizeEstimationList(), - ) - ), - anAttachmentsPreviewState( - mediaInfo = aVideoMediaInfo(), - mediaOptimizationSelectorState = aMediaOptimisationSelectorState( - videoSizeEstimations = aVideoSizeEstimationList(), - displayVideoPresetSelectorDialog = true, - ) - ), - ) -} - -fun anAttachmentsPreviewState( - mediaInfo: MediaInfo = anImageMediaInfo(), - textEditorState: TextEditorState = aTextEditorStateMarkdown(), - sendActionState: SendActionState = SendActionState.Idle, - mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(), - displayFileTooLargeError: Boolean = false, -) = AttachmentsPreviewState( - attachment = Attachment.Media( - localMedia = LocalMedia("file://path".toUri(), mediaInfo), - ), - sendActionState = sendActionState, - textEditorState = textEditorState, - mediaOptimizationSelectorState = mediaOptimizationSelectorState, - displayFileTooLargeError = displayFileTooLargeError, - eventSink = {} -) - -fun aMediaUploadInfo( - filePath: String = "file://path", - thumbnailFilePath: String? = null, -) = MediaUploadInfo.Image( - file = File(filePath), - imageInfo = ImageInfo( - height = 100, - width = 100, - mimetype = MimeTypes.Jpeg, - size = 1000, - thumbnailInfo = null, - thumbnailSource = null, - blurhash = null, - ), - thumbnailFile = thumbnailFilePath?.let { File(it) }, -) - -fun aMediaOptimisationSelectorState( - maxUploadSize: Long = 100 * 1024 * 1024, - videoSizeEstimations: AsyncData> = AsyncData.Success(persistentListOf()), - isImageOptimizationEnabled: Boolean = true, - selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD, - displayMediaSelectorViews: Boolean = true, - displayVideoPresetSelectorDialog: Boolean = false, -) = MediaOptimizationSelectorState( - maxUploadSize = AsyncData.Success(maxUploadSize), - videoSizeEstimations = videoSizeEstimations, - isImageOptimizationEnabled = isImageOptimizationEnabled, - selectedVideoPreset = selectedVideoPreset, - displayMediaSelectorViews = displayMediaSelectorViews, - displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog, - eventSink = {}, -) - -internal fun aVideoSizeEstimationList(): AsyncData> = AsyncData.Success( - persistentListOf( - VideoUploadEstimation( - preset = VideoCompressionPreset.HIGH, - sizeInBytes = 8_200_000L, - canUpload = false, - ), - VideoUploadEstimation( - preset = VideoCompressionPreset.STANDARD, - sizeInBytes = 4_200_000L, - canUpload = true, - ), - ) -) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt deleted file mode 100644 index bb3a07c2fff..00000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ /dev/null @@ -1,444 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview - -import androidx.activity.compose.BackHandler -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.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.messages.impl.R -import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError -import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent -import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState -import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation -import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage -import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo -import io.element.android.libraries.designsystem.components.ProgressDialog -import io.element.android.libraries.designsystem.components.ProgressDialogType -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.dialogs.AlertDialog -import io.element.android.libraries.designsystem.components.dialogs.ListDialog -import io.element.android.libraries.designsystem.components.dialogs.RetryDialog -import io.element.android.libraries.designsystem.components.list.ListItemContent -import io.element.android.libraries.designsystem.modifiers.niceClickable -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.ElementPreviewDark -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.ListItem -import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.Switch -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.mediaviewer.api.local.LocalMedia -import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.textcomposer.TextComposer -import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.libraries.textcomposer.model.VoiceMessageState -import io.element.android.libraries.ui.strings.CommonStrings -import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter -import io.element.android.wysiwyg.display.TextDisplay -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AttachmentsPreviewView( - state: AttachmentsPreviewState, - localMediaRenderer: LocalMediaRenderer, - modifier: Modifier = Modifier, -) { - fun postSendAttachment() { - state.eventSink(AttachmentsPreviewEvent.SendAttachment) - } - - fun postCancel() { - state.eventSink(AttachmentsPreviewEvent.CancelAndDismiss) - } - - fun postClearSendState() { - state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) - } - - BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) { - postCancel() - } - - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - navigationIcon = { - BackButton( - imageVector = CompoundIcons.Close(), - onClick = ::postCancel, - ) - }, - title = {}, - ) - } - ) { paddingValues -> - AttachmentPreviewContent( - modifier = Modifier.padding(paddingValues), - state = state, - localMediaRenderer = localMediaRenderer, - onSendClick = ::postSendAttachment, - ) - } - AttachmentSendStateView( - sendActionState = state.sendActionState, - onDismissClick = ::postClearSendState, - onRetryClick = ::postSendAttachment - ) -} - -@Composable -private fun AttachmentSendStateView( - sendActionState: SendActionState, - onDismissClick: () -> Unit, - onRetryClick: () -> Unit -) { - when (sendActionState) { - is SendActionState.Sending.Processing -> { - if (sendActionState.displayProgress) { - ProgressDialog( - type = ProgressDialogType.Indeterminate, - text = stringResource(CommonStrings.common_preparing), - showCancelButton = true, - onDismissRequest = onDismissClick, - ) - } - } - is SendActionState.Sending.Uploading -> { - ProgressDialog( - type = ProgressDialogType.Indeterminate, - text = stringResource(id = CommonStrings.common_sending), - showCancelButton = true, - onDismissRequest = onDismissClick, - ) - } - is SendActionState.Failure -> { - RetryDialog( - content = stringResource(sendAttachmentError(sendActionState.error)), - onDismiss = onDismissClick, - onRetry = onRetryClick - ) - } - else -> Unit - } -} - -@Composable -private fun AttachmentPreviewContent( - state: AttachmentsPreviewState, - localMediaRenderer: LocalMediaRenderer, - onSendClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxSize() - .navigationBarsPadding(), - ) { - Box( - modifier = Modifier - .weight(1f), - contentAlignment = Alignment.Center - ) { - when (val attachment = state.attachment) { - is Attachment.Media -> { - localMediaRenderer.Render(attachment.localMedia) - } - } - } - val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType - if (mimeType?.isMimeTypeImage() == true) { - ImageOptimizationSelector(state.mediaOptimizationSelectorState) - } else if (mimeType?.isMimeTypeVideo() == true) { - VideoPresetSelector(state = state.mediaOptimizationSelectorState) - } - - val sizeFormatter = rememberFileSizeFormatter() - if (state.displayFileTooLargeError) { - val maxFileUploadSize = state.mediaOptimizationSelectorState.maxUploadSize.dataOrNull() - if (maxFileUploadSize != null) { - val content = stringResource(CommonStrings.dialog_file_too_large_to_upload_subtitle, sizeFormatter.format(maxFileUploadSize, true)) - AlertDialog( - title = stringResource(CommonStrings.dialog_file_too_large_to_upload_title), - content = content, - onDismiss = { state.eventSink(AttachmentsPreviewEvent.CancelAndDismiss) }, - ) - } - } - - AttachmentsPreviewBottomActions( - state = state, - onSendClick = onSendClick, - modifier = Modifier - .fillMaxWidth() - .background(ElementTheme.colors.bgCanvasDefault) - .height(IntrinsicSize.Min) - .imePadding(), - ) - } -} - -@Composable -private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { - if (state.displayMediaSelectorViews == true) { - Row( - modifier = Modifier.fillMaxWidth() - .niceClickable { - state.isImageOptimizationEnabled?.let { value -> - state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value)) - } - } - .padding(horizontal = 16.dp, vertical = 16.dp) - ) { - Text( - modifier = Modifier.weight(1f).align(Alignment.CenterVertically), - text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title), - style = ElementTheme.typography.fontBodyLgRegular, - ) - Switch( - modifier = Modifier.height(32.dp), - checked = state.isImageOptimizationEnabled.orFalse(), - onCheckedChange = { value -> state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(value)) }, - ) - } - } -} - -@Composable -private fun VideoPresetSelector( - state: MediaOptimizationSelectorState, -) { - val videoPresets = state.videoSizeEstimations.dataOrNull() - var selectedPreset by remember(state.selectedVideoPreset) { mutableStateOf(state.selectedVideoPreset) } - - val displayDialog = state.displayVideoPresetSelectorDialog - - val sizeFormatter = rememberFileSizeFormatter() - - if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) { - Column( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp) - .niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) } - ) { - val estimation = videoPresets.find { it.preset == selectedPreset } - val estimationMb = estimation?.sizeInBytes?.let { sizeFormatter.format(it, true) } - val title = buildString { - append(state.selectedVideoPreset.title()) - if (estimationMb != null) { - append(" ($estimationMb)") - } - } - Text(text = title, style = ElementTheme.typography.fontBodyLgMedium) - Text( - text = stringResource(R.string.screen_media_upload_preview_change_video_quality_prompt), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textSecondary, - ) - } - } - - if (displayDialog) { - VideoQualitySelectorDialog( - selectedPreset = selectedPreset ?: VideoCompressionPreset.STANDARD, - videoSizeEstimations = videoPresets ?: persistentListOf(), - maxFileUploadSize = state.maxUploadSize.dataOrNull(), - onSubmit = { preset -> - selectedPreset = preset - state.eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(preset)) - }, - onDismiss = { state.eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog) } - ) - } -} - -@Composable -private fun VideoQualitySelectorDialog( - selectedPreset: VideoCompressionPreset, - videoSizeEstimations: ImmutableList, - maxFileUploadSize: Long?, - onSubmit: (VideoCompressionPreset) -> Unit, - onDismiss: () -> Unit, -) { - val sizeFormatter = rememberFileSizeFormatter() - - var localSelectedPreset by remember(selectedPreset) { mutableStateOf(selectedPreset) } - val subtitlePartNoFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_no_file_size) - val subtitlePartWithFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_file_size) - val subtitle = remember(maxFileUploadSize) { - buildString { - append(subtitlePartNoFileSize) - if (maxFileUploadSize != null) { - append(String.format(subtitlePartWithFileSize, sizeFormatter.format(maxFileUploadSize, true))) - } - } - } - ListDialog( - title = stringResource(CommonStrings.dialog_video_quality_selector_title), - subtitle = subtitle, - onSubmit = { onSubmit(localSelectedPreset) }, - onDismissRequest = onDismiss, - applyPaddingToContents = false, - ) { - for (videoEstimation in videoSizeEstimations) { - val preset = videoEstimation.preset - val isSelected = preset == localSelectedPreset - item( - key = preset, - contentType = preset, - ) { - val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true) - val title = "${preset.title()} ($estimationMb)" - ListItem( - headlineContent = { - Text( - text = title, - style = ElementTheme.typography.fontBodyLgMedium, - ) - }, - supportingContent = { - Text( - text = preset.subtitle(), - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - ) - }, - leadingContent = ListItemContent.RadioButton( - selected = isSelected, - ), - onClick = { - localSelectedPreset = preset - }, - enabled = videoEstimation.canUpload, - ) - } - } - } -} - -@Composable -private fun AttachmentsPreviewBottomActions( - state: AttachmentsPreviewState, - onSendClick: () -> Unit, - modifier: Modifier = Modifier -) { - TextComposer( - modifier = modifier, - state = state.textEditorState, - voiceMessageState = VoiceMessageState.Idle, - composerMode = MessageComposerMode.Attachment, - onRequestFocus = {}, - onSendMessage = onSendClick, - showTextFormatting = false, - onResetComposerMode = {}, - onAddAttachment = {}, - onDismissTextFormatting = {}, - onVoiceRecorderEvent = {}, - onVoicePlayerEvent = {}, - onSendVoiceMessage = {}, - onDeleteVoiceMessage = {}, - onReceiveSuggestion = {}, - resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, - resolveAtRoomMentionDisplay = { TextDisplay.Plain }, - onError = {}, - onTyping = {}, - onSelectRichContent = {}, - ) -} - -// Only preview in dark, dark theme is forced on the Node. -@Preview -@Composable -internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { - AttachmentsPreviewView( - state = state, - localMediaRenderer = object : LocalMediaRenderer { - @Composable - override fun Render(localMedia: LocalMedia) { - Image( - painter = painterResource(id = CommonDrawables.sample_background), - modifier = Modifier.fillMaxSize(), - contentDescription = null, - ) - } - } - ) -} - -@PreviewsDayNight -@Composable -internal fun VideoQualitySelectorDialogPreview() { - ElementPreview { - VideoQualitySelectorDialog( - selectedPreset = VideoCompressionPreset.STANDARD, - videoSizeEstimations = persistentListOf( - VideoUploadEstimation(VideoCompressionPreset.HIGH, 2_000_000, canUpload = false), - VideoUploadEstimation(VideoCompressionPreset.STANDARD, 1_000_000, canUpload = true), - VideoUploadEstimation(VideoCompressionPreset.LOW, 500_000, canUpload = true) - ), - maxFileUploadSize = 1_500_000, - onSubmit = {}, - onDismiss = {}, - ) - } -} - -@Composable -fun VideoCompressionPreset.title(): String { - return stringResource( - when (this) { - VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard - VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high - VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low - } - ) -} - -@Composable -fun VideoCompressionPreset.subtitle(): String { - return stringResource( - when (this) { - VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard_description - VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high_description - VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low_description - } - ) -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt deleted file mode 100644 index 948370fc896..00000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview - -fun interface OnDoneListener { - operator fun invoke() -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt index c81c306f90a..3f6ed26feaf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.VideoMetadataExtractor import io.element.android.libraries.mediaupload.api.compressorHelper import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.preferences.api.store.VideoCompressionPreset diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt index 6a815c48fee..dbf17cc4aea 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt @@ -10,7 +10,7 @@ package io.element.android.features.messages.test.attachments.video import android.net.Uri import android.util.Size -import io.element.android.features.messages.impl.attachments.video.VideoMetadataExtractor +import io.element.android.libraries.mediaupload.api.VideoMetadataExtractor import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 255263cf026..a2c0750b3f3 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -40,8 +40,13 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) implementation(projects.services.appnavstate.api) - api(libs.statemachine) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.textcomposer.impl) + implementation(projects.features.mediapreview.api) + implementation(projects.features.mediapreview.impl) + implementation(libs.timber) api(projects.features.share.api) + api(libs.statemachine) testCommonDependencies(libs, true) testImplementation(projects.features.share.test) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt index 98d4acc472c..79a66aebf66 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt @@ -23,10 +23,10 @@ class DefaultShareEntryPoint : ShareEntryPoint { params: ShareEntryPoint.Params, callback: ShareEntryPoint.Callback, ): Node { - return parentNode.createNode( + return parentNode.createNode( buildContext = buildContext, plugins = listOf( - ShareNode.Inputs(shareIntentData = params.shareIntentData), + ShareFlowNode.Inputs(shareIntentData = params.shareIntentData), callback, ) ) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareFlowNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareFlowNode.kt new file mode 100644 index 00000000000..1124cbe0bdd --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareFlowNode.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.mediapreview.api.MediaPreviewConfig +import io.element.android.features.mediapreview.api.MediaPreviewEntryPoint +import io.element.android.features.mediapreview.api.SendMode +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(SessionScope::class) +@AssistedInject +class ShareFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SharePresenter.Factory, + private val roomSelectEntryPoint: RoomSelectEntryPoint, + private val mediaPreviewEntryPoint: MediaPreviewEntryPoint, + private val localMediaFactory: LocalMediaFactory, + private val matrixClient: MatrixClient, + private val activeRoomsHolder: ActiveRoomsHolder, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.RoomSelect, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object MediaPreview : NavTarget + + @Parcelize + data object RoomSelect : NavTarget + } + + data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs + + private val inputs = inputs() + private val callback: ShareEntryPoint.Callback = callback() + private val sharePresenter: SharePresenter = presenterFactory.create(inputs.shareIntentData) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.RoomSelect -> { + val roomSelectCallback = object : RoomSelectEntryPoint.Callback { + override fun onRoomSelected(roomIds: List) { + sharePresenter.onRoomSelected(roomIds) + if (inputs.shareIntentData is ShareIntentData.Uris) { + backstack.push(NavTarget.MediaPreview) + } else { + callback.onDone(roomIds) + } + } + + override fun onCancel() { + callback.onDone(emptyList()) + } + } + + roomSelectEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = RoomSelectEntryPoint.Params(mode = RoomSelectMode.Share), + callback = roomSelectCallback, + ) + } + is NavTarget.MediaPreview -> { + val shareIntentData = inputs.shareIntentData as ShareIntentData.Uris + val uriToShare = shareIntentData.uris.firstOrNull() + val localMedia = uriToShare?.let { + Timber.d("ShareMediaPreview: Creating LocalMedia for uri: ${it.uri}, mimeType: ${it.mimeType}") + localMediaFactory.createFromUri(it.uri, it.mimeType, null, null) + } + + if (localMedia == null) { + Timber.e("ShareMediaPreview: localMedia is null!") + callback.onDone(emptyList()) + return this + } + + val roomIds = sharePresenter.onRoomSelectedForMediaPreview() + val joinedRoom = roomIds?.firstOrNull()?.let { roomId -> + activeRoomsHolder.getActiveRoom(matrixClient.sessionId) + ?.takeIf { it.roomId == roomId } + ?: runCatching { kotlinx.coroutines.runBlocking { matrixClient.getJoinedRoom(roomId) } }.getOrNull() + } + + val previewCallback = object : MediaPreviewEntryPoint.Callback { + override fun onSend( + caption: String?, + optimizeImage: Boolean, + videoPreset: VideoCompressionPreset?, + onComplete: () -> Unit, + ) { + val roomIds = sharePresenter.onProceedFromPreview(caption, optimizeImage, videoPreset) + if (roomIds != null) { + onComplete() + callback.onDone(roomIds) + } else { + onComplete() + callback.onDone(emptyList()) + } + } + + override fun onCancel() { + backstack.pop() + } + } + + mediaPreviewEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = MediaPreviewEntryPoint.Params( + localMedia = localMedia, + config = MediaPreviewConfig( + initialCaption = shareIntentData.text, + sendMode = SendMode.PREPROCESS, + joinedRoom = joinedRoom, + ), + ), + callback = previewCallback, + ) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView(modifier = modifier) + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt deleted file mode 100644 index 0597b3b678c..00000000000 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.share.impl - -import android.os.Parcelable -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.bumble.appyx.core.composable.Children -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.node.ParentNode -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.features.share.api.ShareEntryPoint -import io.element.android.features.share.api.ShareIntentData -import io.element.android.libraries.architecture.NodeInputs -import io.element.android.libraries.architecture.callback -import io.element.android.libraries.architecture.inputs -import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint -import io.element.android.libraries.roomselect.api.RoomSelectMode -import kotlinx.parcelize.Parcelize - -@ContributesNode(SessionScope::class) -@AssistedInject -class ShareNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - presenterFactory: SharePresenter.Factory, - private val roomSelectEntryPoint: RoomSelectEntryPoint, -) : ParentNode( - navModel = PermanentNavModel( - navTargets = setOf(NavTarget), - savedStateMap = buildContext.savedStateMap, - ), - buildContext = buildContext, - plugins = plugins, -) { - @Parcelize - object NavTarget : Parcelable - - data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs - - private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.shareIntentData) - private val callback: ShareEntryPoint.Callback = callback() - - override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { - val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) { - presenter.onRoomSelected(roomIds) - } - - override fun onCancel() { - callback.onDone(emptyList()) - } - } - - return roomSelectEntryPoint.createNode( - parentNode = this, - buildContext = buildContext, - params = RoomSelectEntryPoint.Params(mode = RoomSelectMode.Share), - callback = callback, - ) - } - - @Composable - override fun View(modifier: Modifier) { - Box(modifier = modifier) { - // Will render to room select screen - Children( - navModel = navModel, - ) - - val state = presenter.present() - ShareView( - state = state, - onShareSuccess = callback::onDone, - ) - } - } -} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 93a9ae3bf47..aab5ca84af9 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -24,8 +24,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.services.appnavstate.api.ActiveRoomsHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -39,7 +40,6 @@ class SharePresenter( private val matrixClient: MatrixClient, private val mediaSenderRoomFactory: MediaSenderRoomFactory, private val activeRoomsHolder: ActiveRoomsHolder, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val onSharedData: OnSharedData, ) : Presenter { @AssistedFactory @@ -48,9 +48,38 @@ class SharePresenter( } private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) + private var updatedCaption: String? = null + private var pendingRoomIds: List? = null + private var optimizeImage: Boolean = true + private var videoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.share(shareIntentData, roomIds) + pendingRoomIds = roomIds + } + + fun onRoomSelectedForMediaPreview(): List? { + return pendingRoomIds + } + + fun onProceedFromPreview( + caption: String?, + optimizeImage: Boolean, + videoPreset: VideoCompressionPreset?, + ): List? { + val roomIds = pendingRoomIds ?: return null + updatedCaption = caption + this.optimizeImage = optimizeImage + this.videoPreset = videoPreset ?: VideoCompressionPreset.STANDARD + return roomIds + } + + fun onConfirmShare() { + val roomIds = pendingRoomIds ?: return + val effectiveShareData = when (shareIntentData) { + is ShareIntentData.Uris -> shareIntentData.copy(text = updatedCaption ?: shareIntentData.text) + else -> shareIntentData + } + sessionCoroutineScope.share(effectiveShareData, roomIds) } @Composable @@ -95,6 +124,10 @@ class SharePresenter( if (filesToShare.isEmpty()) { false } else { + val mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = optimizeImage, + videoCompressionPreset = videoPreset, + ) roomIds .map { roomId -> val room = getJoinedRoom(roomId) ?: return@map false @@ -105,7 +138,7 @@ class SharePresenter( caption = shareIntentData.text, uri = fileToShare.uri, mimeType = fileToShare.mimeType, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + mediaOptimizationConfig = mediaOptimizationConfig, ) // If the coroutine was cancelled, destroy the room and rethrow the exception val cancellationException = result.exceptionOrNull() as? CancellationException diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index fd2c8ff194a..7d41323e248 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -24,9 +24,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory -import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.test.FakeMediaSender import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder @@ -171,7 +169,6 @@ internal fun TestScope.createSharePresenter( matrixClient: MatrixClient = FakeMatrixClient(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, - mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), onSharedData: OnSharedData = OnSharedData {}, ): SharePresenter { return SharePresenter( @@ -180,7 +177,6 @@ internal fun TestScope.createSharePresenter( matrixClient = matrixClient, activeRoomsHolder = activeRoomsHolder, mediaSenderRoomFactory = mediaSenderRoomFactory, - mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, onSharedData = onSharedData, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0078399c02..4eb01260dca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -120,6 +120,8 @@ androidx_preference = "androidx.preference:preference:1.2.1" androidx_webkit = "androidx.webkit:webkit:1.15.0" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } +androidx_compose_foundation = "androidx.compose.foundation:foundation:1.10.0" +androidx_compose_foundation_layout = "androidx.compose.foundation:foundation-layout:1.10.0" androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha15' } androidx_compose_material3_windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } androidx_compose_material3_adaptive = "androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06" diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt index 561a5af79d6..dae4a3949ca 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt @@ -12,8 +12,8 @@ import io.element.android.libraries.androidutils.media.VideoCompressorHelper import io.element.android.libraries.preferences.api.store.VideoCompressionPreset data class MediaOptimizationConfig( - val compressImages: Boolean, - val videoCompressionPreset: VideoCompressionPreset, + val compressImages: Boolean = true, + val videoCompressionPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD, ) fun VideoCompressionPreset.compressorHelper(): VideoCompressorHelper = when (this) { diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/VideoMetadataExtractor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/VideoMetadataExtractor.kt new file mode 100644 index 00000000000..d2aeb7c9f6e --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/VideoMetadataExtractor.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.api + +import android.net.Uri +import android.util.Size +import kotlin.time.Duration + +interface VideoMetadataExtractor : AutoCloseable { + fun getSize(): Result + fun getDuration(): Result + + interface Factory { + fun create(uri: Uri): VideoMetadataExtractor + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultVideoMetadataExtractorFactory.kt similarity index 62% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt rename to libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultVideoMetadataExtractorFactory.kt index a6945b5ebe0..0d8688d1f04 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultVideoMetadataExtractorFactory.kt @@ -6,43 +6,29 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.attachments.video +package io.element.android.libraries.mediaupload.impl import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri import android.util.Size -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.mediaupload.api.VideoMetadataExtractor import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -interface VideoMetadataExtractor : AutoCloseable { - fun getSize(): Result - fun getDuration(): Result - interface Factory { - fun create(uri: Uri): VideoMetadataExtractor +class DefaultVideoMetadataExtractorFactory( + private val context: Context, +) : VideoMetadataExtractor.Factory { + override fun create(uri: Uri): VideoMetadataExtractor { + return DefaultVideoMetadataExtractor(context, uri) } } -@ContributesBinding(AppScope::class) -@AssistedInject -class DefaultVideoMetadataExtractor( - @ApplicationContext private val context: Context, - @Assisted private val uri: Uri, +private class DefaultVideoMetadataExtractor( + private val context: Context, + private val uri: Uri, ) : VideoMetadataExtractor { - @ContributesBinding(AppScope::class) - @AssistedFactory - interface Factory : VideoMetadataExtractor.Factory { - override fun create(uri: Uri): DefaultVideoMetadataExtractor - } - - // Don't use `by lazy` so we can catch any exceptions thrown during initialization private val mediaMetadataRetriever = lazy { MediaMetadataRetriever().apply { setDataSource(context, uri) @@ -55,7 +41,7 @@ class DefaultVideoMetadataExtractor( @Suppress("ComplexCondition") if (width != null && width > 0 && height != null && height > 0) { - Size(width, height) + Size(width, height) } else { error("Could not retrieve video size from metadata for $uri") } diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeVideoMetadataExtractor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeVideoMetadataExtractor.kt new file mode 100644 index 00000000000..e9a85f0c03b --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeVideoMetadataExtractor.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.test + +import android.net.Uri +import android.util.Size +import io.element.android.libraries.mediaupload.api.VideoMetadataExtractor +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class FakeVideoMetadataExtractor( + private val sizeResult: Result = Result.success(Size(1920, 1080)), + private val duration: Result = Result.success(10_000.milliseconds), +) : VideoMetadataExtractor { + override fun getSize(): Result = sizeResult + + override fun getDuration(): Result = duration + + override fun close() = Unit +} + +class FakeVideoMetadataExtractorFactory( + private val fakeVideoMetadataExtractor: FakeVideoMetadataExtractor = FakeVideoMetadataExtractor(), +) : VideoMetadataExtractor.Factory { + override fun create(uri: Uri): VideoMetadataExtractor { + return fakeVideoMetadataExtractor + } +} diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml index ba6c431d8b5..37bdeba06eb 100644 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -7,4 +7,5 @@ "Black" + "Unknown error" diff --git a/tests/uitests/src/test/snapshots/images/features.mediapreview.impl_MediaPreviewView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.mediapreview.impl_MediaPreviewView_Day_0_en.png new file mode 100644 index 00000000000..5a237e1b6da --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.mediapreview.impl_MediaPreviewView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33db404c55957762e348c1519c37f8c7664576d573403607d41928e601c75a8f +size 13849 diff --git a/tests/uitests/src/test/snapshots/images/features.mediapreview.impl_MediaPreviewView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.mediapreview.impl_MediaPreviewView_Night_0_en.png new file mode 100644 index 00000000000..78789614d52 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.mediapreview.impl_MediaPreviewView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:666c7914865a2641fdc5d8f318e0d23282180a5eb4480cd72688eb1c672c2e5c +size 12940