Ensure video send UX uses same max video constraint as transcoding.

This commit is contained in:
Cody Henthorne
2026-06-22 16:23:37 -04:00
committed by jeffrey-signal
parent cdb73d4b8a
commit f22567e4fb
15 changed files with 83 additions and 84 deletions
@@ -259,7 +259,7 @@ public final class AttachmentCompressionJob extends BaseJob {
}
}
StreamingTranscoder transcoder = new StreamingTranscoder(dataSource, options, constraints.getVideoTranscodingSettings(), constraints.getCompressedVideoMaxSize(context), RemoteConfig.allowAudioRemuxing());
StreamingTranscoder transcoder = new StreamingTranscoder(dataSource, options, constraints.getVideoTranscodingSettings(), constraints.getCompressedVideoMaxSize(), RemoteConfig.allowAudioRemuxing());
if (transcoder.isTranscodeRequired()) {
Log.i(TAG, "Compressing with streaming muxer");
@@ -407,13 +407,13 @@ public final class AttachmentCompressionJob extends BaseJob {
ImageCompressionUtil.Result result = null;
try {
for (int size : mediaConstraints.getImageDimensionTargets(context)) {
for (int size : mediaConstraints.getImageDimensionTargets()) {
result = ImageCompressionUtil.compressWithinConstraints(context,
attachment.contentType,
new DecryptableUri(uri),
size,
mediaConstraints.getImageMaxSize(context),
mediaConstraints.getImageCompressionQualitySetting(context));
mediaConstraints.getImageMaxSize(),
mediaConstraints.getImageCompressionQualitySetting());
if (result != null) {
break;
}
@@ -81,9 +81,7 @@ class AttachmentUploadJob private constructor(
@JvmStatic
val maxPlaintextSize: Long
get() {
val maxCipherTextSize = RemoteConfig.maxAttachmentSizeBytes
val maxPaddedSize = AttachmentCipherStreamUtil.getPlaintextLength(maxCipherTextSize)
return PaddingInputStream.getMaxUnpaddedSize(maxPaddedSize)
return AttachmentCipherStreamUtil.getMaxPlaintextSizeForCiphertext(RemoteConfig.maxAttachmentSizeBytes)
}
@JvmStatic
@@ -68,7 +68,7 @@ class MediaSelectionRepository(context: Context) {
return Single.fromCallable {
val populatedMedia = mediaRepository.getPopulatedMedia(context, media)
val result = MediaValidator.filterMedia(context, populatedMedia, mediaConstraints, maxSelection, isStory)
val result = MediaValidator.filterMedia(populatedMedia, mediaConstraints, maxSelection, isStory)
result
}.subscribeOn(Schedulers.io())
}
@@ -165,7 +165,7 @@ class MediaSelectionViewModel(
.subscribe { filterResult ->
if (filterResult.filteredMedia.isNotEmpty()) {
store.update {
val maxDuration = it.calculateMaxVideoDurationUs(getMediaConstraints().getVideoMaxSize())
val maxDuration = it.calculateMaxVideoDurationUs(getMediaConstraints().getEditorVideoMaxSize())
val initializedVideoEditorStates = filterResult.filteredMedia.filterNot { media -> it.editorStateMap.containsKey(media.uri) }
.filter { media -> MediaUtil.isNonGifVideo(media) }
.associate { video: Media ->
@@ -359,7 +359,7 @@ class MediaSelectionViewModel(
val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs
val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs
val endMoved = !isEntireDuration && data.endTimeUs != endTimeUs
val maxVideoDurationUs: Long = it.calculateMaxVideoDurationUs(getMediaConstraints().getVideoMaxSize())
val maxVideoDurationUs: Long = it.calculateMaxVideoDurationUs(getMediaConstraints().getEditorVideoMaxSize())
val preserveStartTime = unedited || !endMoved
val videoTrimData = VideoTrimData(durationEdited, totalDurationUs, clampedStartTime, endTimeUs)
val updatedData = clampToMaxClipDuration(videoTrimData, maxVideoDurationUs, preserveStartTime)
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import androidx.annotation.WorkerThread
import org.signal.core.models.media.Media
import org.signal.core.util.Util
@@ -11,8 +10,8 @@ import org.thoughtcrime.securesms.util.MediaUtil
object MediaValidator {
@WorkerThread
fun filterMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints, maxSelection: Int, isStory: Boolean): FilterResult {
val filteredMedia = filterForValidMedia(context, media, mediaConstraints, isStory)
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
@@ -49,27 +48,27 @@ object MediaValidator {
}
@WorkerThread
private fun filterForValidMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints, isStory: Boolean): List<Media> {
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(context, m, mediaConstraints) || isValidVideo(context, m, mediaConstraints) || isValidDocument(context, m, mediaConstraints)
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(context: Context, media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isGif(media.contentType) && media.size < mediaConstraints.getGifMaxSize(context)
private fun isValidGif(media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isGif(media.contentType) && media.size < mediaConstraints.getGifMaxSize()
}
private fun isValidVideo(context: Context, media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isVideoType(media.contentType) && media.size < mediaConstraints.getUncompressedVideoMaxSize(context)
private fun isValidVideo(media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isVideoType(media.contentType) && media.size < mediaConstraints.getUncompressedVideoMaxSize()
}
private fun isValidDocument(context: Context, media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isDocumentType(media.contentType) && media.size < mediaConstraints.getDocumentMaxSize(context)
private fun isValidDocument(media: Media, mediaConstraints: MediaConstraints): Boolean {
return MediaUtil.isDocumentType(media.contentType) && media.size < mediaConstraints.getDocumentMaxSize()
}
private fun isSupportedMediaType(mimeType: String): Boolean {
@@ -582,7 +582,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
videoTimeLine.unregisterDragListener()
}
val size: Long = tryGetUriSize(requireContext(), uri, Long.MAX_VALUE)
val maxSend = sharedViewModel.getMediaConstraints().getVideoMaxSize()
val maxSend = sharedViewModel.getMediaConstraints().getEditorVideoMaxSize()
if (size > maxSend) {
videoTimeLine.setTimeLimit(state.transcodingPreset.calculateMaxVideoUploadDurationInSeconds(maxSend), TimeUnit.SECONDS)
}
@@ -87,7 +87,7 @@ object MediaSendV3Repository : MediaSendRepository {
): MediaFilterResult = withContext(Dispatchers.IO) {
val populated = MediaRepository().getPopulatedMedia(appContext, media)
val constraints = PushMediaConstraints(null)
val result = MediaValidator.filterMedia(appContext, populated, constraints, maxSelection, isStory)
val result = MediaValidator.filterMedia(populated, constraints, maxSelection, isStory)
val error = mapFilterError(result.filterError, populated, constraints, maxSelection, isStory)
MediaFilterResult(result.filteredMedia, error)
@@ -134,7 +134,7 @@ object MediaSendV3Repository : MediaSendRepository {
}
override fun getVideoMaxSizeBytes(): Long {
return PushMediaConstraints(null).videoMaxSize
return PushMediaConstraints(null).getEditorVideoMaxSize()
}
override fun isVideoTranscodeAvailable(): Boolean {
@@ -263,10 +263,10 @@ object MediaSendV3Repository : MediaSendRepository {
val size = item.size
val isTooLarge = when {
MediaUtil.isGif(contentType) -> size > constraints.getGifMaxSize(appContext)
MediaUtil.isVideoType(contentType) -> size > constraints.getUncompressedVideoMaxSize(appContext)
MediaUtil.isImageType(contentType) -> size > constraints.getImageMaxSize(appContext)
MediaUtil.isDocumentType(contentType) -> size > constraints.getDocumentMaxSize(appContext)
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
}
@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.video.TranscodingPreset;
import org.thoughtcrime.securesms.video.videoconverter.utils.DeviceCapabilities;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import java.util.Arrays;
@@ -33,27 +32,27 @@ public class PushMediaConstraints extends MediaConstraints {
}
@Override
public int getImageMaxWidth(Context context) {
public int getImageMaxWidth() {
return currentConfig.imageSizeTargets[0];
}
@Override
public int getImageMaxHeight(Context context) {
return getImageMaxWidth(context);
public int getImageMaxHeight() {
return getImageMaxWidth();
}
@Override
public int getImageMaxSize(Context context) {
public int getImageMaxSize() {
return (int) Math.min(currentConfig.maxImageFileSize, getMaxAttachmentSize());
}
@Override
public int[] getImageDimensionTargets(Context context) {
public int[] getImageDimensionTargets() {
return currentConfig.imageSizeTargets;
}
@Override
public long getGifMaxSize(Context context) {
public long getGifMaxSize() {
return Math.min(25 * MB, getMaxAttachmentSize());
}
@@ -63,26 +62,25 @@ public class PushMediaConstraints extends MediaConstraints {
}
@Override
public long getUncompressedVideoMaxSize(Context context) {
public long getUncompressedVideoMaxSize() {
return isVideoTranscodeAvailable() ? RemoteConfig.maxSourceTranscodeVideoSizeBytes()
: getVideoMaxSize();
}
@Override
public long getCompressedVideoMaxSize(Context context) {
long maxCipherTextSize = RemoteConfig.videoTranscodeTargetSizeBytes();
long maxPaddedSize = AttachmentCipherStreamUtil.getPlaintextLength(maxCipherTextSize);
public long getCompressedVideoMaxSize() {
long maxCompressedSize = AttachmentCipherStreamUtil.getMaxPlaintextSizeForCiphertext(RemoteConfig.videoTranscodeTargetSizeBytes());
return Math.min(PaddingInputStream.getMaxUnpaddedSize(maxPaddedSize), getMaxAttachmentSize());
return Math.min(maxCompressedSize, getMaxAttachmentSize());
}
@Override
public long getAudioMaxSize(Context context) {
public long getAudioMaxSize() {
return getMaxAttachmentSize();
}
@Override
public long getDocumentMaxSize(Context context) {
public long getDocumentMaxSize() {
return getMaxAttachmentSize();
}
@@ -92,7 +90,7 @@ public class PushMediaConstraints extends MediaConstraints {
}
@Override
public int getImageCompressionQualitySetting(@NonNull Context context) {
public int getImageCompressionQualitySetting() {
return currentConfig.qualitySetting;
}
@@ -1,34 +1,32 @@
package org.thoughtcrime.securesms.profiles;
import android.content.Context;
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.signal.mediasend.MediaConstraints;
public class ProfileMediaConstraints extends MediaConstraints {
@Override
public int getImageMaxWidth(Context context) {
public int getImageMaxWidth() {
return 640;
}
@Override
public int getImageMaxHeight(Context context) {
public int getImageMaxHeight() {
return 640;
}
@Override
public int getImageMaxSize(Context context) {
public int getImageMaxSize() {
return 5 * 1024 * 1024;
}
@Override
public int[] getImageDimensionTargets(Context context) {
return new int[] { getImageMaxWidth(context) };
public int[] getImageDimensionTargets() {
return new int[] { getImageMaxWidth() };
}
@Override
public long getGifMaxSize(Context context) {
public long getGifMaxSize() {
return 0;
}
@@ -38,12 +36,12 @@ public class ProfileMediaConstraints extends MediaConstraints {
}
@Override
public long getAudioMaxSize(Context context) {
public long getAudioMaxSize() {
return 0;
}
@Override
public long getDocumentMaxSize(Context context) {
public long getDocumentMaxSize() {
return 0;
}
@@ -41,13 +41,13 @@ public class SystemProfileUtil {
try {
ImageCompressionUtil.Result result = null;
for (int size : mediaConstraints.getImageDimensionTargets(context)) {
for (int size : mediaConstraints.getImageDimensionTargets()) {
result = ImageCompressionUtil.compressWithinConstraints(context,
ContentTypeUtil.IMAGE_JPEG,
Uri.parse(photoUri),
size,
mediaConstraints.getImageMaxSize(context),
mediaConstraints.getImageCompressionQualitySetting(context));
mediaConstraints.getImageMaxSize(),
mediaConstraints.getImageCompressionQualitySetting());
if (result != null) {
break;
}
@@ -217,8 +217,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
MediaConstraints mediaConstraints = new PushMediaConstraints(SentMediaQuality.HIGH);
imageMaxWidth = mediaConstraints.getImageMaxWidth(requireContext());
imageMaxHeight = mediaConstraints.getImageMaxHeight(requireContext());
imageMaxWidth = mediaConstraints.getImageMaxWidth();
imageMaxHeight = mediaConstraints.getImageMaxHeight();
}
@Nullable
@@ -28,9 +28,9 @@ import kotlin.Pair;
public abstract class MediaConstraints {
private static final String TAG = Log.tag(MediaConstraints.class);
public abstract int getImageMaxWidth(Context context);
public abstract int getImageMaxHeight(Context context);
public abstract int getImageMaxSize(Context context);
public abstract int getImageMaxWidth();
public abstract int getImageMaxHeight();
public abstract int getImageMaxSize();
public TranscodingPreset getVideoTranscodingSettings() {
return TranscodingPreset.LEVEL_1;
@@ -38,41 +38,43 @@ public abstract class MediaConstraints {
/**
* Provide a list of dimensions that should be attempted during compression. We will keep moving
* down the list until the image can be scaled to fit under {@link #getImageMaxSize(Context)}.
* down the list until the image can be scaled to fit under {@link #getImageMaxSize()}.
* The first entry in the list should match your max width/height.
*/
public abstract int[] getImageDimensionTargets(Context context);
public abstract int[] getImageDimensionTargets();
public abstract long getGifMaxSize(Context context);
public abstract long getGifMaxSize();
public abstract long getVideoMaxSize();
public abstract long getAudioMaxSize();
public abstract long getDocumentMaxSize();
public abstract long getMaxAttachmentSize();
public @IntRange(from = 0, to = 100) int getImageCompressionQualitySetting(@NonNull Context context) {
public @IntRange(from = 0, to = 100) int getImageCompressionQualitySetting() {
return 70;
}
public long getUncompressedVideoMaxSize(Context context) {
public long getUncompressedVideoMaxSize() {
return getVideoMaxSize();
}
public long getCompressedVideoMaxSize(Context context) {
public long getCompressedVideoMaxSize() {
return getVideoMaxSize();
}
public abstract long getAudioMaxSize(Context context);
public abstract long getDocumentMaxSize(Context context);
public abstract long getMaxAttachmentSize();
public long getEditorVideoMaxSize() {
return isVideoTranscodeAvailable() ? getCompressedVideoMaxSize() : getVideoMaxSize();
}
public boolean isSatisfied(@NonNull Context context, @NonNull Uri uri, @NonNull String contentType, long size) {
try {
if (size > getMaxAttachmentSize()) {
return false;
}
return (ContentTypeUtil.isGif(contentType) && size <= getGifMaxSize(context) && isWithinBounds(context, uri)) ||
(ContentTypeUtil.isImageType(contentType) && size <= getImageMaxSize(context) && isWithinBounds(context, uri)) ||
(ContentTypeUtil.isAudioType(contentType) && size <= getAudioMaxSize(context)) ||
(ContentTypeUtil.isVideoType(contentType) && size <= getVideoMaxSize()) ||
(ContentTypeUtil.isDocumentType(contentType) && size <= getDocumentMaxSize(context));
return (ContentTypeUtil.isGif(contentType) && size <= getGifMaxSize() && isWithinBounds(context, uri)) ||
(ContentTypeUtil.isImageType(contentType) && size <= getImageMaxSize() && isWithinBounds(context, uri)) ||
(ContentTypeUtil.isAudioType(contentType) && size <= getAudioMaxSize()) ||
(ContentTypeUtil.isVideoType(contentType) && size <= getVideoMaxSize()) ||
(ContentTypeUtil.isDocumentType(contentType) && size <= getDocumentMaxSize());
} catch (IOException ioe) {
Log.w(TAG, "Failed to determine if media's constraints are satisfied.", ioe);
return false;
@@ -81,10 +83,10 @@ public abstract class MediaConstraints {
private boolean isWithinBounds(Context context, Uri uri) throws IOException {
try {
InputStream is = MediaSendDependencies.INSTANCE.getMediaSendRepository().getAttachmentStream(context, uri);
InputStream is = MediaSendDependencies.INSTANCE.getMediaSendRepository().getAttachmentStream(context, uri);
Pair<Integer, Integer> dimensions = BitmapUtil.getDimensions(is);
return dimensions.getFirst() > 0 && dimensions.getFirst() <= getImageMaxWidth(context) &&
dimensions.getSecond() > 0 && dimensions.getSecond() <= getImageMaxHeight(context);
return dimensions.getFirst() > 0 && dimensions.getFirst() <= getImageMaxWidth() &&
dimensions.getSecond() > 0 && dimensions.getSecond() <= getImageMaxHeight();
} catch (BitmapDecodingException e) {
throw new IOException(e);
}
@@ -5,8 +5,6 @@
package org.signal.mediasend;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants;
@@ -15,8 +13,8 @@ public final class VideoUtil {
private VideoUtil() { }
public static int getMaxVideoRecordDurationInSeconds(@NonNull Context context, @NonNull MediaConstraints mediaConstraints) {
long allowedSize = mediaConstraints.getCompressedVideoMaxSize(context);
public static int getMaxVideoRecordDurationInSeconds(@NonNull MediaConstraints mediaConstraints) {
long allowedSize = mediaConstraints.getCompressedVideoMaxSize();
int duration = (int) Math.floor((float) allowedSize / VideoConstants.MAX_ALLOWED_BYTES_PER_SECOND);
return Math.min(duration, VideoConstants.VIDEO_MAX_RECORD_LENGTH_S);
@@ -298,7 +298,7 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
}
private fun getMaxVideoDurationInSeconds(): Int {
var maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller!!.mediaConstraints)
var maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(controller!!.mediaConstraints)
val controllerMaxDuration = controller?.maxVideoDuration ?: 0
if (controllerMaxDuration > 0) {
maxDuration = controllerMaxDuration
@@ -6,6 +6,7 @@
package org.whispersystems.signalservice.api.crypto
import org.signal.core.util.stream.NullOutputStream
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.InputStream
object AttachmentCipherStreamUtil {
@@ -27,6 +28,11 @@ object AttachmentCipherStreamUtil {
return ((ciphertextLength - 16 - 32) / 16 - 1) * 16
}
@JvmStatic
fun getMaxPlaintextSizeForCiphertext(ciphertextMax: Long): Long {
return PaddingInputStream.getMaxUnpaddedSize(getPlaintextLength(ciphertextMax))
}
/**
* Computes the SHA-256 digest of the ciphertext that would result from encrypting [plaintextStream] with the given [key] and [iv].
* This includes the IV prefix and HMAC suffix that are part of the encrypted attachment format.