Add media validation. Move validation into media-send and add tests.

This commit is contained in:
Alex Hart
2026-07-31 15:34:13 -03:00
parent 13ff8142c2
commit e597ee54c8
12 changed files with 467 additions and 190 deletions
@@ -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()
}
}
@@ -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())
}
@@ -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
@@ -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<Media>, 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<Media>, mediaConstraints: MediaConstraints, isStory: Boolean): List<Media> {
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<Media>, 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()
}
}
@@ -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<Media>,
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>): 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<Media>,
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
}
}
}
@@ -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
}
/**
@@ -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).
*/
@@ -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<MediaFilterError>(replay = 1)
val mediaErrors: SharedFlow<MediaFilterError> = _mediaErrors.asSharedFlow()
/** Character count for the message field. */
val messageCharacterCount: Flow<Int> = 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
@@ -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<Media>,
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<Media>,
mediaConstraints: MediaConstraints,
isStory: Boolean,
canSendToStory: (Media) -> Boolean
): List<Media> {
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<Media>, 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()
}
}
@@ -33,6 +33,14 @@ internal fun NavBackStack<NavKey>.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<NavKey>.resetTo(key: NavKey) {
clear()
add(key)
}
internal fun NavBackStack<NavKey>.pop() {
if (isNotEmpty()) {
removeAt(size - 1)
@@ -131,6 +131,12 @@
<string name="MediaSendViewModel__error_taking_photo">Error taking photo</string>
<!-- Displayed when a video was automatically trimmed so it fits within the size limit -->
<string name="MediaSendViewModel__video_trimmed_to_fit">Your video was trimmed to fit within the size limit</string>
<!-- Displayed when one or more of the selected items could not be added because they exceed the size limit -->
<string name="MediaSendViewModel__one_or_more_items_were_too_large">One or more items were too large</string>
<!-- Displayed when one or more of the selected items could not be added because they are not a type that can be sent -->
<string name="MediaSendViewModel__one_or_more_items_were_invalid">One or more items were invalid</string>
<!-- Displayed when the user tries to select more items than can be sent at once -->
<string name="MediaSendViewModel__too_many_items_selected">Too many items selected</string>
<!-- Displayed after the media being edited was successfully saved to the phone\'s storage -->
<string name="MediaSendViewModel__media_saved">Media saved</string>
<!-- Displayed when the media being edited could not be saved to the phone\'s storage -->
@@ -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<Media>(), 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<Media>,
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<TranscodingConfig.QualityTier> = 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
}
}