mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Add media validation. Move validation into media-send and add tests.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
+13
-53
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user