From e597ee54c857646a9163906bd51d2e1ecd78fb6d Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 31 Jul 2026 15:34:13 -0300 Subject: [PATCH] Add media validation. Move validation into media-send and add tests. --- .../mediasend/v2/MediaSelectionActivity.kt | 9 +- .../mediasend/v2/MediaSelectionRepository.kt | 6 +- .../mediasend/v2/MediaSelectionViewModel.kt | 1 + .../securesms/mediasend/v2/MediaValidator.kt | 91 ------ .../mediasend/v3/MediaSendV3Repository.kt | 66 +--- .../signal/mediasend/MediaSendRepository.kt | 14 +- .../org/signal/mediasend/MediaSendState.kt | 5 - .../signal/mediasend/MediaSendViewModel.kt | 50 ++-- .../org/signal/mediasend/MediaValidator.kt | 118 ++++++++ .../mediasend/NavBackStackExtensions.kt | 8 + .../src/main/res/values/strings.xml | 6 + .../signal/mediasend/MediaValidatorTest.kt | 283 ++++++++++++++++++ 12 files changed, 467 insertions(+), 190 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/MediaValidator.kt create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/MediaValidatorTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index 1468171d08..9837b7c97c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -34,6 +34,7 @@ import org.signal.core.util.getParcelableExtraCompat import org.signal.core.util.logging.Log import org.signal.core.util.overrideActivityTransitionCompat import org.signal.mediasend.MediaSendNavKey +import org.signal.mediasend.MediaValidator import org.signal.mediasend.capture.MediaCaptureBottomBar import org.signal.mediasend.capture.MediaCaptureScreenEvent import org.thoughtcrime.securesms.PassphraseRequiredActivity @@ -221,13 +222,11 @@ class MediaSelectionActivity : private fun handleError(error: MediaValidator.FilterError) { when (error) { MediaValidator.FilterError.None -> return - MediaValidator.FilterError.ItemTooLarge -> Toast.makeText(this, R.string.MediaReviewFragment__one_or_more_items_were_too_large, Toast.LENGTH_SHORT).show() - MediaValidator.FilterError.ItemInvalidType -> Toast.makeText(this, R.string.MediaReviewFragment__one_or_more_items_were_invalid, Toast.LENGTH_SHORT).show() + is MediaValidator.FilterError.ItemTooLarge -> Toast.makeText(this, R.string.MediaReviewFragment__one_or_more_items_were_too_large, Toast.LENGTH_SHORT).show() + is MediaValidator.FilterError.ItemInvalidType -> Toast.makeText(this, R.string.MediaReviewFragment__one_or_more_items_were_invalid, Toast.LENGTH_SHORT).show() MediaValidator.FilterError.TooManyItems -> Toast.makeText(this, R.string.MediaReviewFragment__too_many_items_selected, Toast.LENGTH_SHORT).show() is MediaValidator.FilterError.NoItems -> { - if (error.cause != null) { - handleError(error.cause) - } + error.cause?.let { handleError(it) } onNoMediaSelected() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 7dec9ef8f7..3c040ea198 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -14,6 +14,7 @@ import org.signal.core.util.ThreadUtil import org.signal.core.util.logging.Log import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.MediaConstraints +import org.signal.mediasend.MediaValidator import org.signal.mediasend.SentMediaQuality import org.signal.mediasend.edit.video.VideoTrimData import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey @@ -67,8 +68,9 @@ class MediaSelectionRepository(context: Context) { return Single.fromCallable { val populatedMedia = mediaRepository.getPopulatedMedia(context, media) - val result = MediaValidator.filterMedia(populatedMedia, mediaConstraints, maxSelection, isStory) - result + MediaValidator.filterMedia(populatedMedia, mediaConstraints, maxSelection, isStory) { + Stories.MediaTransform.getSendRequirements(it) != Stories.MediaTransform.SendRequirements.CAN_NOT_SEND + } }.subscribeOn(Schedulers.io()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index 5fe2d9a6a6..f7e3c4e42e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -26,6 +26,7 @@ import org.signal.core.util.getParcelableArrayListCompat import org.signal.core.util.getParcelableCompat import org.signal.core.util.logging.Log import org.signal.mediasend.MediaConstraints +import org.signal.mediasend.MediaValidator import org.signal.mediasend.SentMediaQuality import org.signal.mediasend.edit.video.VideoTrimData import org.thoughtcrime.securesms.components.mention.MentionAnnotation diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt deleted file mode 100644 index 6a17a06071..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.thoughtcrime.securesms.mediasend.v2 - -import androidx.annotation.WorkerThread -import org.signal.core.models.media.Media -import org.signal.core.util.Util -import org.signal.mediasend.MediaConstraints -import org.thoughtcrime.securesms.stories.Stories -import org.thoughtcrime.securesms.util.MediaUtil - -object MediaValidator { - - @WorkerThread - fun filterMedia(media: List, mediaConstraints: MediaConstraints, maxSelection: Int, isStory: Boolean): FilterResult { - val filteredMedia = filterForValidMedia(media, mediaConstraints, isStory) - val isAllMediaValid = filteredMedia.size == media.size - - var error: FilterError? = null - if (!isAllMediaValid) { - error = if (media.all { MediaUtil.isImageOrVideoType(it.contentType) || MediaUtil.isDocumentType(it.contentType) }) { - FilterError.ItemTooLarge - } else { - FilterError.ItemInvalidType - } - } - - if (filteredMedia.size > maxSelection) { - error = FilterError.TooManyItems - } - - val truncatedMedia = filteredMedia.take(maxSelection) - val bucketId = if (truncatedMedia.isNotEmpty()) { - truncatedMedia.drop(1).fold(truncatedMedia.first().bucketId ?: Media.ALL_MEDIA_BUCKET_ID) { acc, m -> - if (Util.equals(acc, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { - acc - } else { - Media.ALL_MEDIA_BUCKET_ID - } - } - } else { - Media.ALL_MEDIA_BUCKET_ID - } - - if (truncatedMedia.isEmpty()) { - error = FilterError.NoItems(error) - } - - return FilterResult(truncatedMedia, error, bucketId) - } - - @WorkerThread - private fun filterForValidMedia(media: List, mediaConstraints: MediaConstraints, isStory: Boolean): List { - return media - .filter { m -> isSupportedMediaType(m.contentType!!) } - .filter { m -> - MediaUtil.isImageAndNotGif(m.contentType!!) || isValidGif(m, mediaConstraints) || isValidVideo(m, mediaConstraints) || isValidDocument(m, mediaConstraints) - } - .filter { m -> - !isStory || Stories.MediaTransform.getSendRequirements(m) != Stories.MediaTransform.SendRequirements.CAN_NOT_SEND - } - } - - private fun isValidGif(media: Media, mediaConstraints: MediaConstraints): Boolean { - return MediaUtil.isGif(media.contentType) && media.size < mediaConstraints.getGifMaxSize() - } - - private fun isValidVideo(media: Media, mediaConstraints: MediaConstraints): Boolean { - return MediaUtil.isVideoType(media.contentType) && media.size < mediaConstraints.getUncompressedVideoMaxSize() - } - - private fun isValidDocument(media: Media, mediaConstraints: MediaConstraints): Boolean { - return MediaUtil.isDocumentType(media.contentType) && media.size < mediaConstraints.getDocumentMaxSize() - } - - private fun isSupportedMediaType(mimeType: String): Boolean { - return MediaUtil.isGif(mimeType) || MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType) || MediaUtil.isDocumentType(mimeType) - } - - data class FilterResult(val filteredMedia: List, val filterError: FilterError?, val bucketId: String?) - - sealed class FilterError { - object ItemTooLarge : FilterError() - object ItemInvalidType : FilterError() - object TooManyItems : FilterError() - class NoItems(val cause: FilterError? = null) : FilterError() { - init { - require(cause !is NoItems) - } - } - object None : FilterError() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index aa24876557..2ebf62c941 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -33,6 +33,7 @@ import org.signal.mediasend.MediaFilterResult import org.signal.mediasend.MediaRecipientId import org.signal.mediasend.MediaSendRecipient import org.signal.mediasend.MediaSendRepository +import org.signal.mediasend.MediaValidator import org.signal.mediasend.SaveToStorageResult import org.signal.mediasend.SendRequest import org.signal.mediasend.SendResult @@ -50,7 +51,6 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.MediaRepository import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionRepository -import org.thoughtcrime.securesms.mediasend.v2.MediaValidator import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.PushMediaConstraints import org.thoughtcrime.securesms.mms.TranscodingConfigProvider @@ -109,10 +109,11 @@ object MediaSendV3Repository : MediaSendRepository { ): MediaFilterResult = withContext(Dispatchers.IO) { val populated = MediaRepository().getPopulatedMedia(appContext, media) val constraints = PushMediaConstraints(null) - val result = MediaValidator.filterMedia(populated, constraints, maxSelection, isStory) + val result = MediaValidator.filterMedia(populated, constraints, maxSelection, isStory) { + Stories.MediaTransform.getSendRequirements(it) != Stories.MediaTransform.SendRequirements.CAN_NOT_SEND + } - val error = mapFilterError(result.filterError, populated, constraints, maxSelection, isStory) - MediaFilterResult(result.filteredMedia, error) + MediaFilterResult(result.filteredMedia, mapFilterError(result.filterError, maxSelection)) } override suspend fun getDocumentInfo(media: Media): DocumentInfo? = withContext(Dispatchers.IO) { @@ -362,58 +363,17 @@ object MediaSendV3Repository : MediaSendRepository { }.toMap() } - private fun mapFilterError( - error: MediaValidator.FilterError?, - media: List, - constraints: MediaConstraints, - maxSelection: Int, - isStory: Boolean - ): MediaFilterError? { + /** + * [MediaValidator.FilterError.NoItems] wraps the reason nothing survived, and it is that reason the user needs. The + * emptiness itself travels back as an empty [MediaFilterResult.filteredMedia]. + */ + private fun mapFilterError(error: MediaValidator.FilterError?, maxSelection: Int): MediaFilterError? { return when (error) { - is MediaValidator.FilterError.NoItems -> MediaFilterError.NoItems + is MediaValidator.FilterError.NoItems -> mapFilterError(error.cause, maxSelection) is MediaValidator.FilterError.TooManyItems -> MediaFilterError.TooManyItems(maxSelection) - is MediaValidator.FilterError.ItemInvalidType -> { - findFirstInvalidType(media)?.let { MediaFilterError.ItemInvalidType(it) } - ?: MediaFilterError.Other("One or more items have an invalid type.") - } - is MediaValidator.FilterError.ItemTooLarge -> { - findFirstTooLarge(media, constraints, isStory)?.let { MediaFilterError.ItemTooLarge(it) } - ?: MediaFilterError.Other("One or more items are too large.") - } + is MediaValidator.FilterError.ItemInvalidType -> MediaFilterError.ItemInvalidType(error.media) + is MediaValidator.FilterError.ItemTooLarge -> MediaFilterError.ItemTooLarge(error.media) MediaValidator.FilterError.None, null -> null } } - - private fun findFirstInvalidType(media: List): Media? { - return media.firstOrNull { item -> - val contentType = item.contentType ?: return@firstOrNull true - !MediaUtil.isGif(contentType) && - !MediaUtil.isImageType(contentType) && - !MediaUtil.isVideoType(contentType) && - !MediaUtil.isDocumentType(contentType) - } - } - - private fun findFirstTooLarge( - media: List, - constraints: MediaConstraints, - isStory: Boolean - ): Media? { - return media.firstOrNull { item -> - val contentType = item.contentType ?: return@firstOrNull true - val size = item.size - - val isTooLarge = when { - MediaUtil.isGif(contentType) -> size > constraints.getGifMaxSize() - MediaUtil.isVideoType(contentType) -> size > constraints.getUncompressedVideoMaxSize() - MediaUtil.isImageType(contentType) -> size > constraints.getImageMaxSize() - MediaUtil.isDocumentType(contentType) -> size > constraints.getDocumentMaxSize() - else -> true - } - - val isStoryInvalid = isStory && Stories.MediaTransform.getSendRequirements(item) == Stories.MediaTransform.SendRequirements.CAN_NOT_SEND - - isTooLarge || isStoryInvalid - } - } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index 8463e83390..c299400332 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -171,15 +171,17 @@ enum class SaveToStorageResult { } /** - * Errors that can occur during media filtering. + * Reasons media handed to [MediaSendRepository.validateAndFilterMedia] did not survive filtering. + * + * There is deliberately no "nothing selected" case: an empty selection is a fact the caller already has from + * [MediaFilterResult.filteredMedia], while this type only answers why something was dropped. + * + * @property media The first item that was rejected, or null when the filtering could not pin one down. */ sealed interface MediaFilterError { - data object NoItems : MediaFilterError - data class ItemTooLarge(val media: Media) : MediaFilterError - data class ItemInvalidType(val media: Media) : MediaFilterError + data class ItemTooLarge(val media: Media?) : MediaFilterError + data class ItemInvalidType(val media: Media?) : MediaFilterError data class TooManyItems(val max: Int) : MediaFilterError - data class CannotMixMediaTypes(val message: String) : MediaFilterError - data class Other(val message: String) : MediaFilterError } /** diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt index f50265aa85..9119d78ac4 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt @@ -64,11 +64,6 @@ data class MediaSendState( * Whether touch interactions are enabled (disabled during animations/transitions). */ val isTouchEnabled: Boolean = true, - /** - * When true, suppresses the "no items" error when selection becomes empty. - * Used during camera-first flow exit. - */ - val suppressEmptyError: Boolean = false, /** * Whether a send is currently in flight (prevents duplicate sends). */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 99433c9a03..d663814042 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -27,11 +27,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -152,10 +149,6 @@ class MediaSendViewModel( /** Per-image editor controllers, held here so results arriving from outside the flow can be applied immediately. */ internal val imageControllers = ImageController.Container(BrushWidthsState(internalState.value.brushWidths)) - /** Media filter errors. */ - private val _mediaErrors = MutableSharedFlow(replay = 1) - val mediaErrors: SharedFlow = _mediaErrors.asSharedFlow() - /** Character count for the message field. */ val messageCharacterCount: Flow = state .map { it.message?.let { msg -> StringUtil.getGraphemeCount(msg) } ?: 0 } @@ -552,9 +545,27 @@ class MediaSendViewModel( startUpload(newMedia) } - if (filterResult.error != null) { - _mediaErrors.emit(filterResult.error) - } + filterResult.error?.let { onMediaFilterError(it, isSelectionEmpty = filterResult.filteredMedia.isEmpty()) } + } + } + + /** + * Tells the user why media they picked did not make it into the selection. + * + * When nothing survived there is no editor to show the message on top of, so the flow falls back to whichever screen + * the user can pick again from instead of leaving them on an empty one. + */ + private fun onMediaFilterError(error: MediaFilterError, isSelectionEmpty: Boolean) { + val message = when (error) { + is MediaFilterError.ItemTooLarge -> R.string.MediaSendViewModel__one_or_more_items_were_too_large + is MediaFilterError.ItemInvalidType -> R.string.MediaSendViewModel__one_or_more_items_were_invalid + is MediaFilterError.TooManyItems -> R.string.MediaSendViewModel__too_many_items_selected + } + + internalSnackbarEvents.trySend(SnackbarEvent(message = message)) + + if (isSelectionEmpty && backStack.lastOrNull() == MediaSendNavKey.Edit) { + backStack.resetTo(if (state.value.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select.Folders) } } @@ -667,12 +678,6 @@ class MediaSendViewModel( media.forEach { imageControllers.remove(it.uri) } - if (newSelection.isEmpty() && !snapshot.suppressEmptyError) { - viewModelScope.launch { - _mediaErrors.emit(MediaFilterError.NoItems) - } - } - // Update story requirements viewModelScope.launch { updateStorySendRequirements(newSelection) @@ -1030,7 +1035,6 @@ class MediaSendViewModel( private fun removeCameraFirstCapture() { val capture = internalState.value.cameraFirstCapture ?: return - setSuppressEmptyError(true) removeMedia(capture) } @@ -1045,22 +1049,12 @@ class MediaSendViewModel( //endregion - //region Touch & Error Suppression + //region Touch fun setTouchEnabled(isEnabled: Boolean) { updateState { copy(isTouchEnabled = isEnabled) } } - fun setSuppressEmptyError(isSuppressed: Boolean) { - updateState { copy(suppressEmptyError = isSuppressed) } - } - - fun clearMediaErrors() { - viewModelScope.launch { - _mediaErrors.resetReplayCache() - } - } - //endregion //region Send diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaValidator.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaValidator.kt new file mode 100644 index 0000000000..2c7a6bdcca --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaValidator.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend + +import androidx.annotation.WorkerThread +import org.signal.core.models.media.Media +import org.signal.core.util.ContentTypeUtil +import org.signal.core.util.Util + +object MediaValidator { + + /** + * Drops anything in [media] that cannot be sent, and reports why. + * + * @param canSendToStory Whether a given item is sendable as a story. Only consulted when [isStory] is true, but + * required either way: a default would let a story send silently skip story validation. + */ + @WorkerThread + fun filterMedia( + media: List, + mediaConstraints: MediaConstraints, + maxSelection: Int, + isStory: Boolean, + canSendToStory: (Media) -> Boolean + ): FilterResult { + val filteredMedia = filterForValidMedia(media, mediaConstraints, isStory, canSendToStory) + val isAllMediaValid = filteredMedia.size == media.size + + var error: FilterError? = null + if (!isAllMediaValid) { + val keptUris = filteredMedia.map { it.uri }.toSet() + val rejected = media.firstOrNull { it.uri !in keptUris } + + error = if (media.all { ContentTypeUtil.isImageOrVideoType(it.contentType) || ContentTypeUtil.isDocumentType(it.contentType) }) { + FilterError.ItemTooLarge(rejected) + } else { + FilterError.ItemInvalidType(rejected) + } + } + + if (filteredMedia.size > maxSelection) { + error = FilterError.TooManyItems + } + + val truncatedMedia = filteredMedia.take(maxSelection) + val bucketId = if (truncatedMedia.isNotEmpty()) { + truncatedMedia.drop(1).fold(truncatedMedia.first().bucketId ?: Media.ALL_MEDIA_BUCKET_ID) { acc, m -> + if (Util.equals(acc, m.bucketId ?: Media.ALL_MEDIA_BUCKET_ID)) { + acc + } else { + Media.ALL_MEDIA_BUCKET_ID + } + } + } else { + Media.ALL_MEDIA_BUCKET_ID + } + + if (truncatedMedia.isEmpty()) { + error = FilterError.NoItems(error) + } + + return FilterResult(truncatedMedia, error, bucketId) + } + + @WorkerThread + private fun filterForValidMedia( + media: List, + mediaConstraints: MediaConstraints, + isStory: Boolean, + canSendToStory: (Media) -> Boolean + ): List { + return media + .filter { m -> isSupportedMediaType(m.contentType!!) } + .filter { m -> + ContentTypeUtil.isImageAndNotGif(m.contentType!!) || isValidGif(m, mediaConstraints) || isValidVideo(m, mediaConstraints) || isValidDocument(m, mediaConstraints) + } + .filter { m -> + !isStory || canSendToStory(m) + } + } + + private fun isValidGif(media: Media, mediaConstraints: MediaConstraints): Boolean { + return ContentTypeUtil.isGif(media.contentType) && media.size < mediaConstraints.getGifMaxSize() + } + + private fun isValidVideo(media: Media, mediaConstraints: MediaConstraints): Boolean { + return ContentTypeUtil.isVideoType(media.contentType) && media.size < mediaConstraints.getUncompressedVideoMaxSize() + } + + private fun isValidDocument(media: Media, mediaConstraints: MediaConstraints): Boolean { + return ContentTypeUtil.isDocumentType(media.contentType) && media.size < mediaConstraints.getDocumentMaxSize() + } + + private fun isSupportedMediaType(mimeType: String): Boolean { + return ContentTypeUtil.isGif(mimeType) || ContentTypeUtil.isImageType(mimeType) || ContentTypeUtil.isVideoType(mimeType) || ContentTypeUtil.isDocumentType(mimeType) + } + + data class FilterResult(val filteredMedia: List, val filterError: FilterError?, val bucketId: String?) + + /** + * @param media The first item that was rejected, when one can be pinned down. Only the filtering here knows which + * items it dropped and why, so it reports the offender rather than leaving callers to re-derive it. + */ + sealed class FilterError { + data class ItemTooLarge(val media: Media?) : FilterError() + data class ItemInvalidType(val media: Media?) : FilterError() + object TooManyItems : FilterError() + class NoItems(val cause: FilterError? = null) : FilterError() { + init { + require(cause !is NoItems) + } + } + object None : FilterError() + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt index c0048628d9..1fbcedb7aa 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt @@ -33,6 +33,14 @@ internal fun NavBackStack.goToCamera() { goToSingle(MediaSendNavKey.Capture.Camera) } +/** + * Discards the entire back stack in favor of [key], which becomes the flow's root. Backing out of it leaves the flow. + */ +internal fun NavBackStack.resetTo(key: NavKey) { + clear() + add(key) +} + internal fun NavBackStack.pop() { if (isNotEmpty()) { removeAt(size - 1) diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index b0d3ef32aa..884620426e 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -131,6 +131,12 @@ Error taking photo Your video was trimmed to fit within the size limit + + One or more items were too large + + One or more items were invalid + + Too many items selected Media saved diff --git a/feature/media-send/src/test/java/org/signal/mediasend/MediaValidatorTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/MediaValidatorTest.kt new file mode 100644 index 0000000000..501e156cd4 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/MediaValidatorTest.kt @@ -0,0 +1,283 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend + +import android.app.Application +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.models.media.Media +import org.signal.core.util.ContentTypeUtil +import org.thoughtcrime.securesms.video.TranscodingConfig + +/** + * Covers [MediaValidator.filterMedia]'s two jobs: deciding what survives, and reporting which item did not and why. + * + * Content-type classification runs against the real [ContentTypeUtil], so these also pin down which mime types the + * filtering actually accepts. Nothing is mocked -- story restrictions arrive as a plain predicate. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class MediaValidatorTest { + + private val constraints: MediaConstraints = TestMediaConstraints() + + @Test + fun `Given all valid media, when filtering, then everything survives with no error`() { + val media = listOf( + media(uri = "content://1", contentType = ContentTypeUtil.IMAGE_JPEG), + media(uri = "content://2", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX - 1), + media(uri = "content://3", contentType = PDF, size = DOCUMENT_MAX - 1) + ) + + val result = filter(media) + + assertEquals(media, result.filteredMedia) + assertNull(result.filterError) + } + + @Test + fun `Given an oversized video among valid media, when filtering, then the video is reported as the offender`() { + val image = media(uri = "content://image", contentType = ContentTypeUtil.IMAGE_JPEG) + val oversizedVideo = media(uri = "content://video", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX + 1) + + val result = filter(listOf(image, oversizedVideo)) + + assertEquals(listOf(image), result.filteredMedia) + assertEquals(MediaValidator.FilterError.ItemTooLarge(oversizedVideo), result.filterError) + } + + /** + * Regression: the filtering treats valid as `size < max`, so an item sitting exactly on the limit is dropped. A + * previous re-derivation of the offender elsewhere asked `size > max` and so could not name this item at all. + */ + @Test + fun `Given a video exactly at the size limit, when filtering, then it is dropped and named as the offender`() { + val exactlyAtLimit = media(uri = "content://video", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX) + val image = media(uri = "content://image", contentType = ContentTypeUtil.IMAGE_JPEG) + + val result = filter(listOf(image, exactlyAtLimit)) + + assertEquals(listOf(image), result.filteredMedia) + assertEquals(MediaValidator.FilterError.ItemTooLarge(exactlyAtLimit), result.filterError) + } + + /** + * Regression: non-gif images are accepted at any size, so a huge image is not the offender when something else was + * actually dropped. A previous re-derivation applied an image size limit the filtering never applies, and would + * name the image here because it sorts first. + */ + @Test + fun `Given a huge image and an oversized video, when filtering, then the video is the offender and the image survives`() { + val hugeImage = media(uri = "content://image", contentType = ContentTypeUtil.IMAGE_JPEG, size = IMAGE_MAX * 100L) + val oversizedVideo = media(uri = "content://video", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX + 1) + + val result = filter(listOf(hugeImage, oversizedVideo)) + + assertEquals(listOf(hugeImage), result.filteredMedia) + assertEquals(MediaValidator.FilterError.ItemTooLarge(oversizedVideo), result.filterError) + } + + @Test + fun `Given an unsupported type among valid media, when filtering, then it is reported as an invalid type`() { + val image = media(uri = "content://image", contentType = ContentTypeUtil.IMAGE_JPEG) + val longText = media(uri = "content://text", contentType = ContentTypeUtil.LONG_TEXT) + + val result = filter(listOf(image, longText)) + + assertEquals(listOf(image), result.filteredMedia) + assertEquals(MediaValidator.FilterError.ItemInvalidType(longText), result.filterError) + } + + @Test + fun `Given an oversized gif, when filtering, then it is dropped and named as the offender`() { + val oversizedGif = media(uri = "content://gif", contentType = ContentTypeUtil.IMAGE_GIF, size = GIF_MAX + 1) + + val result = filter(listOf(oversizedGif, media(uri = "content://image", contentType = ContentTypeUtil.IMAGE_JPEG))) + + assertEquals(MediaValidator.FilterError.ItemTooLarge(oversizedGif), result.filterError) + } + + @Test + fun `Given an oversized document, when filtering, then it is dropped and named as the offender`() { + val oversizedDocument = media(uri = "content://doc", contentType = PDF, size = DOCUMENT_MAX + 1) + + val result = filter(listOf(oversizedDocument, media(uri = "content://image", contentType = ContentTypeUtil.IMAGE_JPEG))) + + assertEquals(MediaValidator.FilterError.ItemTooLarge(oversizedDocument), result.filterError) + } + + @Test + fun `Given nothing survives filtering, when filtering, then NoItems carries the underlying reason`() { + val oversizedVideo = media(uri = "content://video", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX + 1) + + val result = filter(listOf(oversizedVideo)) + + val error = result.filterError as MediaValidator.FilterError.NoItems + assertEquals(MediaValidator.FilterError.ItemTooLarge(oversizedVideo), error.cause) + assertEquals(emptyList(), result.filteredMedia) + } + + @Test + fun `Given only an unsupported type, when filtering, then NoItems carries the invalid type reason`() { + val longText = media(uri = "content://text", contentType = ContentTypeUtil.LONG_TEXT) + + val result = filter(listOf(longText)) + + val error = result.filterError as MediaValidator.FilterError.NoItems + assertEquals(MediaValidator.FilterError.ItemInvalidType(longText), error.cause) + } + + @Test + fun `Given no media at all, when filtering, then NoItems has no underlying reason`() { + val result = filter(emptyList()) + + val error = result.filterError as MediaValidator.FilterError.NoItems + assertNull(error.cause) + } + + @Test + fun `Given more media than the max selection, when filtering, then the list is truncated and TooManyItems is reported`() { + val media = (1..5).map { media(uri = "content://$it", contentType = ContentTypeUtil.IMAGE_JPEG) } + + val result = filter(media, maxSelection = 3) + + assertEquals(media.take(3), result.filteredMedia) + assertEquals(MediaValidator.FilterError.TooManyItems, result.filterError) + } + + @Test + fun `Given both an oversized item and too many survivors, when filtering, then TooManyItems wins`() { + val media = (1..5).map { media(uri = "content://$it", contentType = ContentTypeUtil.IMAGE_JPEG) } + + media(uri = "content://video", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX + 1) + + val result = filter(media, maxSelection = 3) + + assertEquals(MediaValidator.FilterError.TooManyItems, result.filterError) + } + + @Test + fun `Given a story with media that cannot be sent, when filtering, then it is dropped and named as the offender`() { + val validForStory = media(uri = "content://image", contentType = ContentTypeUtil.IMAGE_JPEG) + val tooLongVideo = media(uri = "content://video", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX - 1) + + val result = filter(listOf(validForStory, tooLongVideo), isStory = true) { it.uri != tooLongVideo.uri } + + assertEquals(listOf(validForStory), result.filteredMedia) + assertEquals(MediaValidator.FilterError.ItemTooLarge(tooLongVideo), result.filterError) + } + + @Test + fun `Given a non-story send, when filtering, then the story check is not consulted`() { + val media = media(uri = "content://video", contentType = ContentTypeUtil.VIDEO_MP4, size = VIDEO_MAX - 1) + + val result = filter(listOf(media), isStory = false) { error("Story check must not run for a non-story send.") } + + assertEquals(listOf(media), result.filteredMedia) + assertNull(result.filterError) + } + + /** + * The offender is matched by uri, which two items in one selection are not guaranteed to differ on. Callers get a + * null offender rather than a wrong one, which is why the reported item is nullable. + */ + @Test + fun `Given a dropped item sharing a uri with a survivor, when filtering, then no offender is named`() { + val sharedUri = "content://shared" + val image = media(uri = sharedUri, contentType = ContentTypeUtil.IMAGE_JPEG) + val longText = media(uri = sharedUri, contentType = ContentTypeUtil.LONG_TEXT) + + val result = filter(listOf(image, longText)) + + assertEquals(listOf(image), result.filteredMedia) + assertEquals(MediaValidator.FilterError.ItemInvalidType(null), result.filterError) + } + + @Test + fun `Given media all from one bucket, when filtering, then that bucket is returned`() { + val media = listOf( + media(uri = "content://1", contentType = ContentTypeUtil.IMAGE_JPEG, bucketId = "camera"), + media(uri = "content://2", contentType = ContentTypeUtil.IMAGE_JPEG, bucketId = "camera") + ) + + assertEquals("camera", filter(media).bucketId) + } + + @Test + fun `Given media from differing buckets, when filtering, then the all-media bucket is returned`() { + val media = listOf( + media(uri = "content://1", contentType = ContentTypeUtil.IMAGE_JPEG, bucketId = "camera"), + media(uri = "content://2", contentType = ContentTypeUtil.IMAGE_JPEG, bucketId = "downloads") + ) + + assertEquals(Media.ALL_MEDIA_BUCKET_ID, filter(media).bucketId) + } + + @Test + fun `Given no surviving media, when filtering, then the all-media bucket is returned`() { + assertEquals(Media.ALL_MEDIA_BUCKET_ID, filter(emptyList()).bucketId) + } + + private fun filter( + media: List, + maxSelection: Int = 32, + isStory: Boolean = false, + canSendToStory: (Media) -> Boolean = { true } + ): MediaValidator.FilterResult { + return MediaValidator.filterMedia(media, constraints, maxSelection, isStory, canSendToStory) + } + + private fun media( + uri: String, + contentType: String, + size: Long = 1, + bucketId: String? = Media.ALL_MEDIA_BUCKET_ID + ): Media { + return Media( + uri = Uri.parse(uri), + contentType = contentType, + date = 0, + width = 0, + height = 0, + size = size, + duration = 0, + isBorderless = false, + isVideoGif = false, + bucketId = bucketId, + caption = null, + transformProperties = null, + fileName = null + ) + } + + /** Fixed, small limits so the boundaries under test are obvious. */ + private class TestMediaConstraints : MediaConstraints() { + override fun getImageMaxSize(): Int = IMAGE_MAX + override fun getGifMaxSize(): Long = GIF_MAX + override fun getVideoMaxSize(): Long = VIDEO_MAX + override fun getDocumentMaxSize(): Long = DOCUMENT_MAX + + override fun getImageMaxWidth(): Int = 0 + override fun getImageMaxHeight(): Int = 0 + override fun getImageDimensionTargets(): IntArray = intArrayOf() + override fun getVideoTranscodingSettings(): List = emptyList() + override fun getAudioMaxSize(): Long = Long.MAX_VALUE + override fun getMaxAttachmentSize(): Long = Long.MAX_VALUE + } + + companion object { + private const val PDF = "application/pdf" + + private const val IMAGE_MAX = 1_000 + private const val GIF_MAX = 2_000L + private const val VIDEO_MAX = 3_000L + private const val DOCUMENT_MAX = 4_000L + } +}