Preclude cancelation of pre-uploaded video attachments.

Addresses ##10225.
This commit is contained in:
Nicholas Tinsley 2024-08-30 12:23:26 -04:00
parent 2b1bbdda15
commit d683b8a321
13 changed files with 137 additions and 56 deletions

View file

@ -1941,6 +1941,10 @@ class ConversationFragment :
onComplete = {
onSendComplete()
afterSendComplete()
},
onError = {
Log.w(TAG, "Error received during send!", it)
toast(R.string.ConversationActivity_error_sending_media)
}
)
}

View file

@ -225,7 +225,7 @@ class ConversationRepository(
emitter.onComplete()
}
} else {
MessageSender.sendPushWithPreUploadedMedia(
val sendSuccessful = MessageSender.sendPushWithPreUploadedMedia(
AppDependencies.application,
message,
preUploadResults,
@ -233,6 +233,10 @@ class ConversationRepository(
) {
emitter.onComplete()
}
if (!sendSuccessful) {
emitter.tryOnError(IllegalStateException("Could not send pre-uploaded attachments because they did not exist!"))
}
}
}

View file

@ -36,6 +36,7 @@ import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.StreamUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.count
import org.signal.core.util.delete
import org.signal.core.util.deleteAll
import org.signal.core.util.drain
@ -45,6 +46,7 @@ import org.signal.core.util.groupBy
import org.signal.core.util.isNull
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBlob
@ -433,6 +435,20 @@ class AttachmentTable(
.run()
}
/**
* Takes a list of attachment IDs and confirms they exist in the database.
*/
fun hasAttachments(ids: List<AttachmentId>): Boolean {
return ids.size == SqlUtil.buildCollectionQuery(ID, ids.map { it.id }).sumOf { query ->
readableDatabase
.count()
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToSingleInt(defaultValue = 0)
}
}
fun getPendingAttachments(): List<DatabaseAttachment> {
return readableDatabase
.select(*PROJECTION)
@ -1103,7 +1119,7 @@ class AttachmentTable(
@Throws(MmsException::class)
fun insertAttachmentForPreUpload(attachment: Attachment): DatabaseAttachment {
Log.d(TAG, "Inserting attachment $attachment for pre-upload.")
Log.d(TAG, "Inserting attachment ${attachment.uri} for pre-upload.")
val result = insertAttachmentsForMessage(PREUPLOAD_MESSAGE_ID, listOf(attachment), emptyList())
if (result.values.isEmpty()) {

View file

@ -227,12 +227,17 @@ public final class AttachmentCompressionJob extends BaseJob {
@NonNull TranscoderCancelationSignal cancelationSignal)
throws UndeliverableMessageException
{
if (cancelationSignal.isCanceled()) {
throw new UndeliverableMessageException("Job is canceled!");
}
AttachmentTable.TransformProperties transformProperties = attachment.transformProperties;
boolean allowSkipOnFailure = false;
if (!MediaConstraints.isVideoTranscodeAvailable()) {
if (transformProperties.getVideoEdited()) {
if (transformProperties != null && transformProperties.getVideoEdited()) {
throw new UndeliverableMessageException("Video edited, but transcode is not available");
}
return attachment;
@ -284,6 +289,9 @@ public final class AttachmentCompressionJob extends BaseJob {
PartProgressEvent.Type.COMPRESSION,
100,
100));
if (cancelationSignal.isCanceled()) {
throw new UndeliverableMessageException("Job is canceled!");
}
final Mp4FaststartPostProcessor postProcessor = new Mp4FaststartPostProcessor(() -> {
try {
@ -355,7 +363,7 @@ public final class AttachmentCompressionJob extends BaseJob {
}
}
} catch (VideoSourceException | EncodingException | MemoryFileException e) {
if (attachment.size > constraints.getVideoMaxSize(context)) {
if (attachment.size > constraints.getVideoMaxSize()) {
throw new UndeliverableMessageException("Duration not found, attachment too large to skip transcode", e);
} else {
if (allowSkipOnFailure) {

View file

@ -166,7 +166,7 @@ public class MediaUploadRepository {
PreUploadResult result = uploadResults.get(media);
if (result != null) {
Log.d(TAG, "Canceling upload jobs for " + result.getJobIds().size() + " media items.");
Log.d(TAG, "Canceling attachment upload job for " + result.getAttachmentId());
Stream.of(result.getJobIds()).forEach(jobManager::cancel);
uploadResults.remove(media);
SignalDatabase.attachments().deleteAttachment(result.getAttachmentId());

View file

@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.TranscodingPreset
import kotlin.time.Duration.Companion.seconds
data class MediaSelectionState(
val sendType: MessageSendType,
@ -44,6 +45,14 @@ data class MediaSelectionState(
return editorStateMap[uri] as? VideoTrimData ?: VideoTrimData()
}
fun calculateMaxVideoDurationUs(maxFileSize: Long): Long {
return if (isStory && !MediaConstraints.isVideoTranscodeAvailable()) {
Stories.MAX_VIDEO_DURATION_MILLIS
} else {
transcodingPreset.calculateMaxVideoUploadDurationInSeconds(maxFileSize).seconds.inWholeMicroseconds
}
}
enum class ViewOnceToggleState(val code: Int) {
INFINITE(0),
ONCE(1);

View file

@ -46,7 +46,6 @@ import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Collections
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* ViewModel which maintains the list of selected media and other shared values.
@ -127,7 +126,7 @@ class MediaSelectionViewModel(
}
if (initialMedia.isNotEmpty()) {
addMedia(initialMedia)
addMedia(initialMedia.toSet())
}
disposables += selectedMediaSubject
@ -165,7 +164,7 @@ class MediaSelectionViewModel(
}
fun addMedia(media: Media) {
addMedia(listOf(media))
addMedia(setOf(media))
}
fun isStory(): Boolean {
@ -176,7 +175,7 @@ class MediaSelectionViewModel(
return store.state.storySendRequirements
}
private fun addMedia(media: List<Media>) {
private fun addMedia(media: Set<Media>) {
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
addAll(store.state.selectedMedia)
addAll(media)
@ -188,11 +187,16 @@ class MediaSelectionViewModel(
.subscribe { filterResult ->
if (filterResult.filteredMedia.isNotEmpty()) {
store.update {
val maxDuration = it.calculateMaxVideoDurationUs(getMediaConstraints().getVideoMaxSize())
val initializedVideoEditorStates = filterResult.filteredMedia.filterNot { media -> it.editorStateMap.containsKey(media.uri) }
.filter { media -> MediaUtil.isNonGifVideo(media) }
.associate { video: Media ->
val duration = video.duration.milliseconds.inWholeMicroseconds
video.uri to VideoTrimData(false, duration, 0, duration)
if (duration < maxDuration) {
video.uri to VideoTrimData(false, duration, 0, duration)
} else {
video.uri to VideoTrimData(true, duration, 0, maxDuration)
}
}
it.copy(
selectedMedia = filterResult.filteredMedia,
@ -341,7 +345,7 @@ class MediaSelectionViewModel(
store.update { it.copy(viewOnceToggleState = it.viewOnceToggleState.next()) }
}
fun onEditVideoDuration(context: Context, totalDurationUs: Long, startTimeUs: Long, endTimeUs: Long, touchEnabled: Boolean) {
fun onEditVideoDuration(totalDurationUs: Long, startTimeUs: Long, endTimeUs: Long, touchEnabled: Boolean) {
store.update {
val uri = it.focusedMedia?.uri ?: return@update it
val data = it.getOrCreateVideoTrimData(uri)
@ -351,27 +355,30 @@ class MediaSelectionViewModel(
val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs
val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs
val endMoved = !isEntireDuration && data.endTimeUs != endTimeUs
val maxVideoDurationUs: Long = if (it.isStory && !MediaConstraints.isVideoTranscodeAvailable()) {
Stories.MAX_VIDEO_DURATION_MILLIS
} else {
it.transcodingPreset.calculateMaxVideoUploadDurationInSeconds(getMediaConstraints().getVideoMaxSize(context)).seconds.inWholeMicroseconds
}
val maxVideoDurationUs: Long = it.calculateMaxVideoDurationUs(getMediaConstraints().getVideoMaxSize())
val preserveStartTime = unedited || !endMoved
val videoTrimData = VideoTrimData(durationEdited, totalDurationUs, clampedStartTime, endTimeUs)
val updatedData = clampToMaxClipDuration(videoTrimData, maxVideoDurationUs, preserveStartTime)
if (updatedData != videoTrimData) {
Log.d(TAG, "Video trim clamped from ${videoTrimData.startTimeUs}, ${videoTrimData.endTimeUs} to ${updatedData.startTimeUs}, ${updatedData.endTimeUs}")
Log.d(TAG, "Video attachment trim clamped from ${videoTrimData.startTimeUs}, ${videoTrimData.endTimeUs} to ${updatedData.startTimeUs}, ${updatedData.endTimeUs}")
}
if (unedited && durationEdited) {
Log.d(TAG, "Canceling upload because the duration has been edited for the first time..")
Log.d(TAG, "Canceling attachment upload because the duration has been edited for the first time..")
cancelUpload(MediaBuilder.buildMedia(uri))
}
it.copy(
isTouchEnabled = touchEnabled,
editorStateMap = it.editorStateMap + (uri to updatedData)
)
if (updatedData != data) {
Log.d(TAG, "Updating video attachment trim data for $uri")
it.copy(
isTouchEnabled = touchEnabled,
editorStateMap = it.editorStateMap + (uri to updatedData)
)
} else {
Log.d(TAG, "Preserving video attachment trim data for $uri")
it.copy(isTouchEnabled = touchEnabled)
}
}
}

View file

@ -114,8 +114,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
private var disposables: LifecycleDisposable = LifecycleDisposable()
private var sentMediaQuality: SentMediaQuality = SignalStore.settings.sentMediaQuality
private var viewOnceToggleState: MediaSelectionState.ViewOnceToggleState = MediaSelectionState.ViewOnceToggleState.default
private var scheduledSendTime: Long? = null
private var readyToSend = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
@ -202,6 +202,14 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
sendButton.setOnClickListener {
if (!readyToSend) {
Log.d(TAG, "Attachment send button not currently enabled. Ignoring click event.")
return@setOnClickListener
} else {
Log.d(TAG, "Attachment send button enabled. Processing click event.")
readyToSend = false
}
val viewOnce: Boolean = sharedViewModel.state.value?.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE
if (sharedViewModel.isContactSelectionRequired) {
@ -216,7 +224,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
val snapshot = sharedViewModel.state.value
if (snapshot != null) {
sendButton.isEnabled = false
readyToSend = false
SimpleTask.run(viewLifecycleOwner.lifecycle, {
snapshot.selectedMedia.take(2).map { media ->
val editorData = snapshot.editorStateMap[media.uri]
@ -232,7 +240,6 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
}
}, {
sendButton.isEnabled = true
storiesLauncher.launch(StoriesMultiselectForwardActivity.Args(args, it))
})
} else {
@ -249,7 +256,15 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
Log.d(TAG, "Performing send add to group story dialog.")
performSend()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.setNegativeButton(android.R.string.cancel) { _, _ ->
readyToSend = true
}
.setOnCancelListener {
readyToSend = true
}
.setOnDismissListener {
readyToSend = true
}
.show()
scheduledSendTime = null
} else {
@ -325,7 +340,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
state.selectedMedia.map { MediaReviewSelectedItem.Model(it, state.focusedMedia == it) } + MediaReviewAddItem.Model
)
presentSendButton(state.sendType, state.recipient)
presentSendButton(readyToSend, state.sendType, state.recipient)
presentPager(state)
presentAddMessageEntry(state.viewOnceToggleState, state.message)
presentImageQualityToggle(state)
@ -449,6 +464,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
private fun performSend(selection: List<ContactSearchKey> = listOf()) {
Log.d(TAG, "Performing attachment send.")
readyToSend = false
progressWrapper.visible = true
progressWrapper.animate()
.setStartDelay(300)
@ -456,11 +473,20 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
.alpha(1f)
disposables += sharedViewModel
.send(selection.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java), scheduledSendTime)
.send(selection.filterIsInstance<ContactSearchKey.RecipientSearchKey>(), scheduledSendTime)
.subscribe(
{ result -> callback.onSentWithResult(result) },
{ error -> callback.onSendError(error) },
{ callback.onSentWithoutResult() }
{ result ->
callback.onSentWithResult(result)
readyToSend = true
},
{ error ->
callback.onSendError(error)
readyToSend = true
},
{
callback.onSentWithoutResult()
readyToSend = true
}
)
}
@ -500,8 +526,9 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
)
}
private fun presentSendButton(sendType: MessageSendType, recipient: Recipient?) {
private fun presentSendButton(enabled: Boolean, sendType: MessageSendType, recipient: Recipient?) {
val sendButtonBackgroundTint = when {
!enabled -> ContextCompat.getColor(requireContext(), R.color.core_grey_50)
recipient != null -> recipient.chatColors.asSingleColor()
sendType.usesSignalTransport -> ContextCompat.getColor(requireContext(), R.color.signal_colorOnSecondaryContainer)
else -> ContextCompat.getColor(requireContext(), R.color.core_grey_50)
@ -513,6 +540,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
val sendButtonForegroundTint = when {
!enabled -> ContextCompat.getColor(requireContext(), R.color.signal_colorSecondaryContainer)
recipient != null -> ContextCompat.getColor(requireContext(), R.color.signal_colorOnCustom)
else -> ContextCompat.getColor(requireContext(), R.color.signal_colorSecondaryContainer)
}
@ -549,7 +577,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(requireContext())
val maxSend = sharedViewModel.getMediaConstraints().getVideoMaxSize()
if (size > maxSend) {
videoTimeLine.setTimeLimit(state.transcodingPreset.calculateMaxVideoUploadDurationInSeconds(maxSend), TimeUnit.SECONDS)
}
@ -791,6 +819,6 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
override fun onRangeDrag(minValue: Long, maxValue: Long, duration: Long, end: Boolean) {
sharedViewModel.onEditVideoDuration(context = requireContext(), totalDurationUs = duration, startTimeUs = minValue, endTimeUs = maxValue, touchEnabled = end)
sharedViewModel.onEditVideoDuration(totalDurationUs = duration, startTimeUs = minValue, endTimeUs = maxValue, touchEnabled = end)
}
}

View file

@ -76,7 +76,7 @@ class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), Vide
}
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelableCompat(ARG_URI, Uri::class.java))
private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext())
private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize()
private fun requireIsVideoGif(): Boolean = requireNotNull(requireArguments().getBoolean(ARG_IS_VIDEO_GIF))
companion object {

View file

@ -49,18 +49,18 @@ public abstract class MediaConstraints {
public abstract int[] getImageDimensionTargets(Context context);
public abstract long getGifMaxSize(Context context);
public abstract long getVideoMaxSize(Context context);
public abstract long getVideoMaxSize();
public @IntRange(from = 0, to = 100) int getImageCompressionQualitySetting(@NonNull Context context) {
return 70;
}
public long getUncompressedVideoMaxSize(Context context) {
return getVideoMaxSize(context);
return getVideoMaxSize();
}
public long getCompressedVideoMaxSize(Context context) {
return getVideoMaxSize(context);
return getVideoMaxSize();
}
public abstract long getAudioMaxSize(Context context);
@ -79,7 +79,7 @@ public abstract class MediaConstraints {
return (MediaUtil.isGif(attachment) && size <= getGifMaxSize(context) && isWithinBounds(context, attachment.getUri())) ||
(MediaUtil.isImage(attachment) && size <= getImageMaxSize(context) && isWithinBounds(context, attachment.getUri())) ||
(MediaUtil.isAudio(attachment) && size <= getAudioMaxSize(context)) ||
(MediaUtil.isVideo(attachment) && size <= getVideoMaxSize(context)) ||
(MediaUtil.isVideo(attachment) && size <= getVideoMaxSize()) ||
(MediaUtil.isFile(attachment) && size <= getDocumentMaxSize(context));
} catch (IOException ioe) {
Log.w(TAG, "Failed to determine if media's constraints are satisfied.", ioe);
@ -95,7 +95,7 @@ public abstract class MediaConstraints {
return (MediaUtil.isGif(contentType) && size <= getGifMaxSize(context) && isWithinBounds(context, uri)) ||
(MediaUtil.isImageType(contentType) && size <= getImageMaxSize(context) && isWithinBounds(context, uri)) ||
(MediaUtil.isAudioType(contentType) && size <= getAudioMaxSize(context)) ||
(MediaUtil.isVideoType(contentType) && size <= getVideoMaxSize(context)) ||
(MediaUtil.isVideoType(contentType) && size <= getVideoMaxSize()) ||
size <= getDocumentMaxSize(context);
} catch (IOException ioe) {
Log.w(TAG, "Failed to determine if media's constraints are satisfied.", ioe);

View file

@ -53,14 +53,14 @@ public class PushMediaConstraints extends MediaConstraints {
}
@Override
public long getVideoMaxSize(Context context) {
public long getVideoMaxSize() {
return getMaxAttachmentSize();
}
@Override
public long getUncompressedVideoMaxSize(Context context) {
return isVideoTranscodeAvailable() ? RemoteConfig.maxSourceTranscodeVideoSizeBytes()
: getVideoMaxSize(context);
: getVideoMaxSize();
}
@Override

View file

@ -32,7 +32,7 @@ public class ProfileMediaConstraints extends MediaConstraints {
}
@Override
public long getVideoMaxSize(Context context) {
public long getVideoMaxSize() {
return 0;
}

View file

@ -52,10 +52,10 @@ import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob;
import org.thoughtcrime.securesms.jobs.AttachmentCopyJob;
import org.thoughtcrime.securesms.jobs.AttachmentMarkUploadedJob;
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.thoughtcrime.securesms.jobs.IndividualSendJob;
import org.thoughtcrime.securesms.jobs.ProfileKeySendJob;
import org.thoughtcrime.securesms.jobs.PushDistributionListSendJob;
import org.thoughtcrime.securesms.jobs.PushGroupSendJob;
import org.thoughtcrime.securesms.jobs.IndividualSendJob;
import org.thoughtcrime.securesms.jobs.ReactionSendJob;
import org.thoughtcrime.securesms.jobs.RemoteDeleteSendJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@ -251,12 +251,11 @@ public class MessageSender {
}
}
public static long sendPushWithPreUploadedMedia(final Context context,
final OutgoingMessage message,
final Collection<PreUploadResult> preUploadResults,
final long threadId,
final MessageTable.InsertListener insertListener)
{
public static boolean sendPushWithPreUploadedMedia(final Context context,
final OutgoingMessage message,
final Collection<PreUploadResult> preUploadResults,
final long threadId,
final MessageTable.InsertListener insertListener) {
Log.i(TAG, "Sending media message with pre-uploads to " + message.getThreadRecipient().getId() + ", thread: " + threadId + ", pre-uploads: " + preUploadResults);
Preconditions.checkArgument(message.getAttachments().isEmpty(), "If the media is pre-uploaded, there should be no attachments on the message.");
@ -267,24 +266,30 @@ public class MessageSender {
Recipient recipient = message.getThreadRecipient();
long allocatedThreadId = threadTable.getOrCreateValidThreadId(message.getThreadRecipient(), threadId);
long messageId = mmsDatabase.insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, recipient, message, allocatedThreadId),
allocatedThreadId,
false,
insertListener);
List<AttachmentId> attachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList();
List<String> jobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList();
if (!attachmentDatabase.hasAttachments(attachmentIds)) {
Log.w(TAG, "Attachments not found in database for " + message.getThreadRecipient().getId() + ", thread: " + threadId + ", pre-uploads: " + preUploadResults);
return false;
}
long messageId = mmsDatabase.insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, recipient, message, allocatedThreadId),
allocatedThreadId,
false,
insertListener);
attachmentDatabase.updateMessageId(attachmentIds, messageId, message.getStoryType().isStory());
sendMessageInternal(context, recipient, SendType.SIGNAL, messageId, jobIds, false);
onMessageSent();
threadTable.update(allocatedThreadId, true, true);
return allocatedThreadId;
return true;
} catch (MmsException e) {
Log.w(TAG, e);
return threadId;
return false;
}
}