From 6a385c7a229fb0626985f6fc5e5b1be8574a5166 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 21 Jun 2022 16:05:52 -0300 Subject: [PATCH] Implement video length enforcement for Stories. --- .../contacts/paged/ContactSearchItems.kt | 15 +- .../contacts/paged/ContactSearchMediator.kt | 13 +- .../conversation/ConversationFragment.java | 5 +- .../forward/MultiselectForwardBottomSheet.kt | 8 +- .../forward/MultiselectForwardFragment.kt | 57 +++- ...tiselectForwardFullScreenDialogFragment.kt | 11 +- .../forward/MultiselectForwardRepository.kt | 16 ++ .../forward/MultiselectForwardState.kt | 5 +- .../forward/MultiselectForwardViewModel.kt | 15 + .../database/AttachmentDatabase.java | 14 + .../jobs/AttachmentCompressionJob.java | 4 +- .../securesms/keyvalue/StoryValues.kt | 16 +- .../mediasend/MediaUploadRepository.java | 2 +- .../mediasend/v2/MediaSelectionActivity.kt | 8 +- .../mediasend/v2/MediaSelectionRepository.kt | 106 +++++-- .../mediasend/v2/MediaSelectionState.kt | 4 +- .../mediasend/v2/MediaSelectionViewModel.kt | 29 +- .../securesms/mediasend/v2/MediaValidator.kt | 7 +- .../v2/stories/ChooseGroupStoryBottomSheet.kt | 21 +- .../v2/text/send/TextStoryPostSendFragment.kt | 4 +- .../v2/videos/MediaReviewVideoPageFragment.kt | 3 +- .../securesms/sharing/MultiShareArgs.java | 29 +- .../securesms/sharing/MultiShareSender.java | 29 ++ .../securesms/sms/MessageSender.java | 18 +- .../thoughtcrime/securesms/stories/Stories.kt | 262 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 + 26 files changed, 597 insertions(+), 108 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt index 6e56cf7112..e19cab9a43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt @@ -15,6 +15,9 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder import org.thoughtcrime.securesms.util.visible +private typealias StoryClickListener = (View, ContactSearchData.Story, Boolean) -> Unit +private typealias RecipientClickListener = (View, ContactSearchData.KnownRecipient, Boolean) -> Unit + /** * Mapping Models and View Holders for ContactSearchData */ @@ -22,8 +25,8 @@ object ContactSearchItems { fun register( mappingAdapter: MappingAdapter, displayCheckBox: Boolean, - recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit, - storyListener: (ContactSearchData.Story, Boolean) -> Unit, + recipientListener: RecipientClickListener, + storyListener: StoryClickListener, expandListener: (ContactSearchData.Expand) -> Unit ) { mappingAdapter.registerFactory( @@ -79,7 +82,7 @@ object ContactSearchItems { } } - private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder(itemView, displayCheckBox, onClick) { + private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: StoryClickListener) : BaseRecipientViewHolder(itemView, displayCheckBox, onClick) { override fun isSelected(model: StoryModel): Boolean = model.isSelected override fun getData(model: StoryModel): ContactSearchData.Story = model.story override fun getRecipient(model: StoryModel): Recipient = model.story.recipient @@ -125,7 +128,7 @@ object ContactSearchItems { } } - private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder(itemView, displayCheckBox, onClick) { + private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: RecipientClickListener) : BaseRecipientViewHolder(itemView, displayCheckBox, onClick) { override fun isSelected(model: RecipientModel): Boolean = model.isSelected override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient @@ -134,7 +137,7 @@ object ContactSearchItems { /** * Base Recipient View Holder */ - private abstract class BaseRecipientViewHolder, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (D, Boolean) -> Unit) : MappingViewHolder(itemView) { + private abstract class BaseRecipientViewHolder, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (View, D, Boolean) -> Unit) : MappingViewHolder(itemView) { protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image) protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge) @@ -147,7 +150,7 @@ object ContactSearchItems { override fun bind(model: T) { checkbox.visible = displayCheckBox checkbox.isChecked = isSelected(model) - itemView.setOnClickListener { onClick(getData(model), isSelected(model)) } + itemView.setOnClickListener { onClick(itemView, getData(model), isSelected(model)) } if (payload.isNotEmpty()) { return diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 1a5a8d244a..a3e5d09345 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.contacts.paged +import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModelProvider @@ -13,12 +14,14 @@ class ContactSearchMediator( recyclerView: RecyclerView, selectionLimits: SelectionLimits, displayCheckBox: Boolean, - mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration + mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, + private val contactSelectionPreFilter: (View?, Set) -> Set = { _, s -> s } ) { private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository())).get(ContactSearchViewModel::class.java) init { + val adapter = PagingMappingAdapter() recyclerView.adapter = adapter @@ -54,7 +57,7 @@ class ContactSearchMediator( } fun setKeysSelected(keys: Set) { - viewModel.setKeysSelected(keys) + viewModel.setKeysSelected(contactSelectionPreFilter(null, keys)) } fun setKeysNotSelected(keys: Set) { @@ -73,11 +76,11 @@ class ContactSearchMediator( viewModel.addToVisibleGroupStories(groupStories) } - private fun toggleSelection(contactSearchData: ContactSearchData, isSelected: Boolean) { - if (isSelected) { + private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) { + return if (isSelected) { viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey)) } else { - viewModel.setKeysSelected(setOf(contactSearchData.contactSearchKey)) + viewModel.setKeysSelected(contactSelectionPreFilter(view, setOf(contactSearchData.contactSearchKey))) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index a6e4cc93e8..6f504c5e60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -161,6 +161,7 @@ import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.stickers.StickerLocator; import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity; +import org.thoughtcrime.securesms.stories.Stories; import org.thoughtcrime.securesms.stories.StoryViewerArgs; import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity; import org.thoughtcrime.securesms.util.CachedInflater; @@ -1422,8 +1423,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect } @Override - public boolean canSendMediaToStories() { - return true; + public @Nullable Stories.MediaTransform.SendRequirements getStorySendRequirements() { + return null; } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardBottomSheet.kt index 2471a3c8c9..30921e06d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardBottomSheet.kt @@ -10,6 +10,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.fragments.findListener class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), MultiselectForwardFragment.Callback { @@ -43,10 +44,9 @@ class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragmen return backgroundColor } - override fun canSendMediaToStories(): Boolean { - return findListener()?.canSendMediaToStories() ?: true + override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? { + return findListener()?.getStorySendRequirements() } - override fun setResult(bundle: Bundle) { setFragmentResult(MultiselectForwardFragment.RESULT_KEY, bundle) } @@ -71,6 +71,6 @@ class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragmen interface Callback { fun onFinishForwardAction() fun onDismissForwardSheet() - fun canSendMediaToStories(): Boolean = true + fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index ee4398ed01..7dc3c2f8fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -14,6 +14,8 @@ import android.widget.EditText import android.widget.TextView import android.widget.Toast import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible @@ -26,6 +28,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ContactFilterView +import org.thoughtcrime.securesms.components.TooltipPopup import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator @@ -80,6 +83,7 @@ class MultiselectForwardFragment : private lateinit var contactFilterView: ContactFilterView private lateinit var addMessage: EditText private lateinit var contactSearchMediator: ContactSearchMediator + private lateinit var contactSearchRecycler: RecyclerView private lateinit var callback: Callback private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null @@ -111,8 +115,8 @@ class MultiselectForwardFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { view.minimumHeight = resources.displayMetrics.heightPixels - val contactSearchRecycler: RecyclerView = view.findViewById(R.id.contact_selection_list) - contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !isSingleRecipientSelection(), this::getConfiguration) + contactSearchRecycler = view.findViewById(R.id.contact_selection_list) + contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !isSingleRecipientSelection(), this::getConfiguration, this::filterContacts) callback = findListener()!! disposables.bindTo(viewLifecycleOwner.lifecycle) @@ -356,6 +360,51 @@ class MultiselectForwardFragment : viewModel.cancelSend() } + private fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements { + return requireListener().getStorySendRequirements() ?: viewModel.snapshot.storySendRequirements + } + + private fun filterContacts(view: View?, contactSet: Set): Set { + val storySendRequirements = getStorySendRequirements() + val resultsSet = contactSet.filterNot { + it is ContactSearchKey.RecipientSearchKey && it.isStory && storySendRequirements == Stories.MediaTransform.SendRequirements.CAN_NOT_SEND + } + + if (view != null && contactSet.any { it is ContactSearchKey.RecipientSearchKey && it.isStory }) { + @Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT") + when (storySendRequirements) { + Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> { + if (!SignalStore.storyValues().videoTooltipSeen) { + displayTooltip(view, R.string.MultiselectForwardFragment__videos_will_be_trimmed) { + SignalStore.storyValues().videoTooltipSeen = true + } + } + } + Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> { + if (!SignalStore.storyValues().cannotSendTooltipSeen) { + displayTooltip(view, R.string.MultiselectForwardFragment__videos_sent_to_stories_cant) { + SignalStore.storyValues().cannotSendTooltipSeen = true + } + } + } + } + } + + return resultsSet.toSet() + } + + private fun displayTooltip(anchor: View, @StringRes text: Int, onDismiss: () -> Unit) { + TooltipPopup + .forTarget(anchor) + .setText(text) + .setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_colorOnPrimary)) + .setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary)) + .setOnDismissListener { + onDismiss() + } + .show(TooltipPopup.POSITION_BELOW) + } + private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration { return findListener()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build { query = contactSearchState.query @@ -417,7 +466,7 @@ class MultiselectForwardFragment : } private fun isSelectedMediaValidForStories(): Boolean { - return requireListener().canSendMediaToStories() && getMultiShareArgs().all { it.isValidForStories } + return getMultiShareArgs().all { it.isValidForStories } } private fun isSelectedMediaValidForNonStories(): Boolean { @@ -439,7 +488,7 @@ class MultiselectForwardFragment : fun setResult(bundle: Bundle) fun getContainer(): ViewGroup fun getDialogBackgroundColor(): Int - fun canSendMediaToStories(): Boolean = true + fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFullScreenDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFullScreenDialogFragment.kt index be3d314828..d3643bf58b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFullScreenDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFullScreenDialogFragment.kt @@ -7,6 +7,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.setFragmentResult import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.FullScreenDialogFragment +import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.fragments.findListener class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), MultiselectForwardFragment.Callback { @@ -33,6 +34,10 @@ class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), M return ContextCompat.getColor(requireContext(), R.color.signal_background_primary) } + override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? { + return findListener()?.getStorySendRequirements() + } + override fun getContainer(): ViewGroup { return requireView().findViewById(R.id.full_screen_dialog_content) as ViewGroup } @@ -47,12 +52,8 @@ class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), M override fun onSearchInputFocused() = Unit - override fun canSendMediaToStories(): Boolean { - return findListener()?.canSendMediaToStories() ?: true - } - interface Callback { fun onFinishForwardAction() = Unit - fun canSendMediaToStories(): Boolean = true + fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt index c340d1300f..1e8f2a17c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.database.SignalDatabase @@ -8,6 +9,7 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sharing.MultiShareArgs import org.thoughtcrime.securesms.sharing.MultiShareSender +import org.thoughtcrime.securesms.stories.Stories import java.util.Optional class MultiselectForwardRepository { @@ -18,6 +20,20 @@ class MultiselectForwardRepository { val onAllMessagesFailed: () -> Unit ) + fun checkAllSelectedMediaCanBeSentToStories(records: List): Single { + if (!Stories.isFeatureEnabled() || records.isEmpty()) { + return Single.just(Stories.MediaTransform.SendRequirements.CAN_NOT_SEND) + } + + return Single.fromCallable { + if (records.any { !it.isValidForStories }) { + Stories.MediaTransform.SendRequirements.CAN_NOT_SEND + } else { + Stories.MediaTransform.getSendRequirements(records.map { it.media }.flatten()) + } + }.subscribeOn(Schedulers.io()) + } + fun canSelectRecipient(recipientId: Optional): Single { if (!recipientId.isPresent) { return Single.just(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt index 21f9ec0e5c..a74bb7e19f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt @@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.stories.Stories data class MultiselectForwardState( - val stage: Stage = Stage.Selection + val stage: Stage = Stage.Selection, + val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND ) { + sealed class Stage { object Selection : Stage() object FirstConfirmation : Stage() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt index 74e78b1d1b..59d629ff3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords @@ -18,6 +20,19 @@ class MultiselectForwardViewModel( private val store = Store(MultiselectForwardState()) val state: LiveData = store.stateLiveData + val snapshot: MultiselectForwardState get() = store.state + + private val disposables = CompositeDisposable() + + init { + disposables += repository.checkAllSelectedMediaCanBeSentToStories(records).subscribe { sendRequirements -> + store.update { it.copy(storySendRequirements = sendRequirements) } + } + } + + override fun onCleared() { + disposables.clear() + } fun send(additionalMessage: String, selectedContacts: Set) { if (SignalStore.tooltips().showMultiForwardDialog()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index fabae7cb13..dede16d1e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -80,6 +80,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -1535,5 +1536,18 @@ public class AttachmentDatabase extends Database { return empty(); } } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final TransformProperties that = (TransformProperties) o; + return skipTransform == that.skipTransform && videoTrim == that.videoTrim && videoTrimStartTimeUs == that.videoTrimStartTimeUs && videoTrimEndTimeUs == that.videoTrimEndTimeUs && sentMediaQuality == that.sentMediaQuality; + } + + @Override + public int hashCode() { + return Objects.hash(skipTransform, videoTrim, videoTrimStartTimeUs, videoTrimEndTimeUs, sentMediaQuality); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index 82bc004747..86a8608c95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -241,7 +241,7 @@ public final class AttachmentCompressionJob extends BaseJob { } MediaStream mediaStream = new MediaStream(ModernDecryptingPartInputStream.createFor(attachmentSecret, file, 0), MimeTypes.VIDEO_MP4, 0, 0); - attachmentDatabase.updateAttachmentData(attachment, mediaStream, transformProperties.isVideoEdited()); + attachmentDatabase.updateAttachmentData(attachment, mediaStream, true); } finally { if (!file.delete()) { Log.w(TAG, "Failed to delete temp file"); @@ -267,7 +267,7 @@ public final class AttachmentCompressionJob extends BaseJob { percent)); }, cancelationSignal); - attachmentDatabase.updateAttachmentData(attachment, mediaStream, transformProperties.isVideoEdited()); + attachmentDatabase.updateAttachmentData(attachment, mediaStream, true); attachmentDatabase.markAttachmentAsTransformed(attachment.getAttachmentId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt index 6c5b4aec17..5ebe2e57e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt @@ -23,11 +23,21 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { * Rolling window of latest two private or group stories a user has sent to. */ private const val LATEST_STORY_SENDS = "latest.story.sends" + + /** + * Video Trim tooltip marker + */ + private const val VIDEO_TOOLTIP_SEEN_MARKER = "stories.video.will.be.trimmed.tooltip.seen" + + /** + * Cannot send to story tooltip marker + */ + private const val CANNOT_SEND_SEEN_MARKER = "stories.cannot.send.video.tooltip.seen" } override fun onFirstEverAppLaunch() = Unit - override fun getKeysToIncludeInBackup(): MutableList = mutableListOf(MANUAL_FEATURE_DISABLE, USER_HAS_ADDED_TO_A_STORY) + override fun getKeysToIncludeInBackup(): MutableList = mutableListOf(MANUAL_FEATURE_DISABLE, USER_HAS_ADDED_TO_A_STORY, VIDEO_TOOLTIP_SEEN_MARKER, CANNOT_SEND_SEEN_MARKER) var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false) @@ -35,6 +45,10 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { var userHasBeenNotifiedAboutStories: Boolean by booleanValue(USER_HAS_ADDED_TO_A_STORY, false) + var videoTooltipSeen by booleanValue(VIDEO_TOOLTIP_SEEN_MARKER, false) + + var cannotSendTooltipSeen by booleanValue(CANNOT_SEND_SEEN_MARKER, false) + fun setLatestStorySend(storySend: StorySend) { synchronized(this) { val storySends: List = getList(LATEST_STORY_SENDS, StorySendSerializer) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java index f786345620..5f55eee009 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java @@ -144,7 +144,7 @@ public class MediaUploadRepository { @WorkerThread private void uploadMediaInternal(@NonNull Media media, @Nullable Recipient recipient) { Attachment attachment = asAttachment(context, media); - PreUploadResult result = MessageSender.preUploadPushAttachment(context, attachment, recipient); + PreUploadResult result = MessageSender.preUploadPushAttachment(context, attachment, recipient, MediaUtil.isVideo(media.getMimeType())); if (result != null) { uploadResults.put(media, result); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index 95985415e3..525788858f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -329,6 +329,10 @@ class MediaSelectionActivity : } } + override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements { + return viewModel.getStorySendRequirements() + } + private inner class OnBackPressed : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val navController = Navigation.findNavController(this@MediaSelectionActivity, R.id.fragment_container) @@ -467,8 +471,4 @@ class MediaSelectionActivity : } } } - - override fun canSendMediaToStories(): Boolean { - return viewModel.canShareSelectedMediaToStory() - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 4dbc9b471a..4e987f6d6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -35,13 +35,14 @@ import org.thoughtcrime.securesms.mms.OutgoingMediaMessage import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.scribbles.ImageEditorFragment import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult -import org.thoughtcrime.securesms.sms.OutgoingStoryMessage import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MessageUtil import java.util.Collections import java.util.concurrent.TimeUnit @@ -128,12 +129,22 @@ class MediaSelectionRepository(context: Context) { ) } + val clippedMediaForStories = if (singleContact?.isStory == true || contacts.any { it.isStory }) { + updatedMedia.filter { MediaUtil.isVideo(it.mimeType) }.map { media -> + if (Stories.MediaTransform.getSendRequirements(media) == Stories.MediaTransform.SendRequirements.REQUIRES_CLIP) { + Stories.MediaTransform.clipMediaToStoryDuration(media) + } else { + listOf(media) + } + }.flatten() + } else emptyList() + uploadRepository.applyMediaUpdates(oldToNewMediaMap, singleRecipient) uploadRepository.updateCaptions(updatedMedia) uploadRepository.updateDisplayOrder(updatedMedia) uploadRepository.getPreUploadResults { uploadResults -> if (contacts.isNotEmpty()) { - sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce) + sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce, clippedMediaForStories) uploadRepository.deleteAbandonedAttachments() emitter.onComplete() } else if (uploadResults.isNotEmpty()) { @@ -210,10 +221,19 @@ class MediaSelectionRepository(context: Context) { } @WorkerThread - private fun sendMessages(contacts: List, body: String, preUploadResults: Collection, mentions: List, isViewOnce: Boolean) { - val broadcastMessages: MutableList = ArrayList(contacts.size) - val storyMessages: MutableMap> = mutableMapOf() - val distributionListSentTimestamps: MutableMap = mutableMapOf() + private fun sendMessages( + contacts: List, + body: String, + preUploadResults: Collection, + mentions: List, + isViewOnce: Boolean, + storyClips: List + ) { + val nonStoryMessages: MutableList = ArrayList(contacts.size) + val storyPreUploadMessages: MutableMap> = mutableMapOf() + val storyClipMessages: MutableList = ArrayList() + val distributionListPreUploadSentTimestamps: MutableMap = mutableMapOf() + val distributionListStoryClipsSentTimestamps: MutableMap = mutableMapOf() for (contact in contacts) { val recipient = Recipient.resolved(contact.recipientId) @@ -237,7 +257,7 @@ class MediaSelectionRepository(context: Context) { recipient, body, emptyList(), - if (recipient.isDistributionList) distributionListSentTimestamps.getOrPut(preUploadResults.first()) { System.currentTimeMillis() } else System.currentTimeMillis(), + if (recipient.isDistributionList) distributionListPreUploadSentTimestamps.getOrPut(preUploadResults.first()) { System.currentTimeMillis() } else System.currentTimeMillis(), -1, TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()), isViewOnce, @@ -254,18 +274,57 @@ class MediaSelectionRepository(context: Context) { null ) - if (isStory && preUploadResults.size > 1) { - preUploadResults.forEach { - val list = storyMessages[it] ?: mutableListOf() - list.add(OutgoingSecureMediaMessage(message).withSentTimestamp(if (recipient.isDistributionList) distributionListSentTimestamps.getOrPut(it) { System.currentTimeMillis() } else System.currentTimeMillis())) - storyMessages[it] = list + if (isStory) { + preUploadResults.filterNot { it.isVideo }.forEach { + val list = storyPreUploadMessages[it] ?: mutableListOf() + list.add( + OutgoingSecureMediaMessage(message).withSentTimestamp( + if (recipient.isDistributionList) { + distributionListPreUploadSentTimestamps.getOrPut(it) { System.currentTimeMillis() } + } else { + System.currentTimeMillis() + } + ) + ) + storyPreUploadMessages[it] = list + + // XXX We must do this to avoid sending out messages to the same recipient with the same + // sentTimestamp. If we do this, they'll be considered dupes by the receiver. + ThreadUtil.sleep(5) + } + + storyClips.forEach { + storyClipMessages.add( + OutgoingSecureMediaMessage( + OutgoingMediaMessage( + recipient, + body, + listOf(VideoSlide(context, it.uri, it.size, it.isVideoGif, it.width, it.height, it.caption.orElse(null), it.transformProperties.orElse(null)).asAttachment()), + if (recipient.isDistributionList) distributionListStoryClipsSentTimestamps.getOrPut(it) { System.currentTimeMillis() } else System.currentTimeMillis(), + -1, + TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()), + isViewOnce, + ThreadDatabase.DistributionTypes.DEFAULT, + storyType, + null, + false, + null, + emptyList(), + emptyList(), + mentions, + mutableSetOf(), + mutableSetOf(), + null + ) + ) + ) // XXX We must do this to avoid sending out messages to the same recipient with the same // sentTimestamp. If we do this, they'll be considered dupes by the receiver. ThreadUtil.sleep(5) } } else { - broadcastMessages.add(OutgoingSecureMediaMessage(message)) + nonStoryMessages.add(OutgoingSecureMediaMessage(message)) // XXX We must do this to avoid sending out messages to the same recipient with the same // sentTimestamp. If we do this, they'll be considered dupes by the receiver. @@ -273,19 +332,26 @@ class MediaSelectionRepository(context: Context) { } } - if (broadcastMessages.isNotEmpty()) { + if (nonStoryMessages.isNotEmpty()) { + Log.d(TAG, "Sending ${nonStoryMessages.size} non-story preupload messages") MessageSender.sendMediaBroadcast( context, - broadcastMessages, + nonStoryMessages, preUploadResults, - storyMessages.flatMap { (preUploadResult, messages) -> - messages.map { OutgoingStoryMessage(it, preUploadResult) } - } + Collections.emptyList() ) - } else { - storyMessages.forEach { (preUploadResult, messages) -> + } + + if (storyPreUploadMessages.isNotEmpty()) { + Log.d(TAG, "Sending ${storyPreUploadMessages.size} preload messages to stories") + storyPreUploadMessages.forEach { (preUploadResult, messages) -> MessageSender.sendMediaBroadcast(context, messages, Collections.singleton(preUploadResult), Collections.emptyList()) } } + + if (storyClipMessages.isNotEmpty()) { + Log.d(TAG, "Sending ${storyClipMessages.size} clip messages to stories") + MessageSender.sendStories(context, storyClipMessages, null, null) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt index 2ca383d71f..eb5e862c12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt @@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendConstants import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.stories.Stories data class MediaSelectionState( val sendType: MessageSendType, @@ -22,7 +23,8 @@ data class MediaSelectionState( val isMeteredConnection: Boolean = false, val editorStateMap: Map = mapOf(), val cameraFirstCapture: Media? = null, - val isStory: Boolean + val isStory: Boolean, + val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND ) { val maxSelection = if (sendType.usesSmsTransport) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index ab0015c3ed..c99212bf0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -9,7 +9,11 @@ import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.Subject import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType @@ -20,7 +24,7 @@ import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.scribbles.ImageEditorFragment -import org.thoughtcrime.securesms.sharing.MultiShareArgs +import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.SingleLiveEvent import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.livedata.Store @@ -39,6 +43,8 @@ class MediaSelectionViewModel( private val repository: MediaSelectionRepository ) : ViewModel() { + private val selectedMediaSubject: Subject> = BehaviorSubject.create() + private val store: Store = Store( MediaSelectionState( sendType = sendType, @@ -83,6 +89,14 @@ class MediaSelectionViewModel( if (initialMedia.isNotEmpty()) { addMedia(initialMedia) } + + disposables += selectedMediaSubject.map { media -> + Stories.MediaTransform.getSendRequirements(media) + }.subscribeBy { requirements -> + store.update { + it.copy(storySendRequirements = requirements) + } + } } override fun onCleared() { @@ -110,6 +124,10 @@ class MediaSelectionViewModel( return store.state.isStory } + fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements { + return store.state.storySendRequirements + } + private fun addMedia(media: List) { val newSelectionList: List = linkedSetOf().apply { addAll(store.state.selectedMedia) @@ -128,6 +146,8 @@ class MediaSelectionViewModel( ) } + selectedMediaSubject.onNext(filterResult.filteredMedia) + val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList() startUpload(newMedia) } @@ -212,6 +232,7 @@ class MediaSelectionViewModel( mediaErrors.postValue(MediaValidator.FilterError.NoItems()) } + selectedMediaSubject.onNext(newMediaList) repository.deleteBlobs(listOf(media)) cancelUpload(media) @@ -345,10 +366,6 @@ class MediaSelectionViewModel( return store.state.selectedMedia.isNotEmpty() } - fun canShareSelectedMediaToStory(): Boolean { - return store.state.selectedMedia.all { MultiShareArgs.isValidStoryDuration(it) } - } - fun onRestoreState(savedInstanceState: Bundle) { val selection: List = savedInstanceState.getParcelableArrayList(STATE_SELECTION) ?: emptyList() val focused: Media? = savedInstanceState.getParcelable(STATE_FOCUSED) @@ -362,6 +379,8 @@ class MediaSelectionViewModel( val editorStates: List = savedInstanceState.getParcelableArrayList(STATE_EDITORS) ?: emptyList() val editorStateMap = editorStates.associate { it.toAssociation() } + selectedMediaSubject.onNext(selection) + store.update { state -> state.copy( selectedMedia = selection, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt index ebb942d72c..893266e128 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaValidator.kt @@ -1,14 +1,16 @@ package org.thoughtcrime.securesms.mediasend.v2 import android.content.Context +import androidx.annotation.WorkerThread import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mms.MediaConstraints -import org.thoughtcrime.securesms.sharing.MultiShareArgs +import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.Util object MediaValidator { + @WorkerThread fun filterMedia(context: Context, media: List, mediaConstraints: MediaConstraints, maxSelection: Int, isStory: Boolean): FilterResult { val filteredMedia = filterForValidMedia(context, media, mediaConstraints, isStory) val isAllMediaValid = filteredMedia.size == media.size @@ -46,6 +48,7 @@ object MediaValidator { return FilterResult(truncatedMedia, error, bucketId) } + @WorkerThread private fun filterForValidMedia(context: Context, media: List, mediaConstraints: MediaConstraints, isStory: Boolean): List { return media .filter { m -> isSupportedMediaType(m.mimeType) } @@ -53,7 +56,7 @@ object MediaValidator { MediaUtil.isImageAndNotGif(m.mimeType) || isValidGif(context, m, mediaConstraints) || isValidVideo(context, m, mediaConstraints) } .filter { m -> - MediaConstraints.isVideoTranscodeAvailable() || !isStory || MultiShareArgs.isValidStoryDuration(m) + !isStory || Stories.MediaTransform.getSendRequirements(m) != Stories.MediaTransform.SendRequirements.CAN_NOT_SEND } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt index 8a09e79999..7cec89245d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt @@ -65,19 +65,20 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( this, contactRecycler, FeatureFlags.shareSelectionLimit(), - true - ) { state -> - ContactSearchConfiguration.build { - query = state.query + true, + { state -> + ContactSearchConfiguration.build { + query = state.query - addSection( - ContactSearchConfiguration.Section.Groups( - includeHeader = false, - returnAsGroupStories = true + addSection( + ContactSearchConfiguration.Section.Groups( + includeHeader = false, + returnAsGroupStories = true + ) ) - ) + } } - } + ) mediator.getSelectionState().observe(viewLifecycleOwner) { state -> adapter.submitList( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt index 6e3298e4a4..7b12f822af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt @@ -123,7 +123,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm } val contactsRecyclerView: RecyclerView = view.findViewById(R.id.contacts_container) - contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit(), true) { contactSearchState -> + contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit(), true, { contactSearchState -> ContactSearchConfiguration.build { query = contactSearchState.query @@ -135,7 +135,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm ) ) } - } + }) contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { selection -> shareSelectionAdapter.submitList(selection.mapIndexed { index, contact -> ShareSelectionMappingModel(contact.requireShareContact(), index == 0) }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt index dc57abca63..3afb821ff8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.mediasend.VideoEditorFragment import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel +import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.stories.Stories private const val VIDEO_EDITOR_TAG = "video.editor.fragment" @@ -102,7 +103,7 @@ class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), Vide private fun requireMaxCompressedVideoSize(): Long = sharedViewModel.getMediaConstraints().getCompressedVideoMaxSize(requireContext()).toLong() private fun requireMaxAttachmentSize(): Long = sharedViewModel.getMediaConstraints().getVideoMaxSize(requireContext()).toLong() private fun requireIsVideoGif(): Boolean = requireNotNull(requireArguments().getBoolean(ARG_IS_VIDEO_GIF)) - private fun requireMaxVideoDuration(): Long = if (sharedViewModel.isStory()) Stories.MAX_VIDEO_DURATION_MILLIS else Long.MAX_VALUE + private fun requireMaxVideoDuration(): Long = if (sharedViewModel.isStory() && !MediaConstraints.isVideoTranscodeAvailable()) Stories.MAX_VIDEO_DURATION_MILLIS else Long.MAX_VALUE companion object { private const val ARG_URI = "arg.uri" diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java index eca8b10550..32738a00d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java @@ -12,7 +12,6 @@ import com.annimon.stream.Stream; import org.signal.core.util.BreakIteratorCompat; import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; -import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mediasend.Media; @@ -27,7 +26,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; public final class MultiShareArgs implements Parcelable { @@ -152,12 +150,8 @@ public final class MultiShareArgs implements Parcelable { public boolean isValidForStories() { return isTextStory || - !media.isEmpty() && media.stream().allMatch( - m -> MediaUtil.isImageOrVideoType(m.getMimeType()) && - isValidStoryDuration(m) - ) || - MediaUtil.isImageType(dataType) || - MediaUtil.isVideoType(dataType) || + (!media.isEmpty() && media.stream().allMatch(m -> MediaUtil.isStorySupportedType(m.getMimeType()))) || + MediaUtil.isStorySupportedType(dataType) || isValidForTextStoryGeneration(); } @@ -165,25 +159,6 @@ public final class MultiShareArgs implements Parcelable { return !isTextStory; } - public static boolean isValidStoryDuration(@NonNull Media media) { - if (MediaUtil.isVideoType(media.getMimeType())) { - if (media.getDuration() > 0 && media.getDuration() <= Stories.MAX_VIDEO_DURATION_MILLIS) { - return true; - } else if (media.getTransformProperties().isPresent()) { - AttachmentDatabase.TransformProperties transformProperties = media.getTransformProperties().get(); - if (transformProperties.isVideoTrim()) { - return transformProperties.getVideoTrimEndTimeUs() - transformProperties.getVideoTrimStartTimeUs() <= TimeUnit.MILLISECONDS.toMicros(Stories.MAX_VIDEO_DURATION_MILLIS); - } else { - return false; - } - } else { - return false; - } - } else { - return true; - } - } - public boolean isValidForTextStoryGeneration() { if (isTextStory || !media.isEmpty()) { return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index a20c235e3c..2233ef3317 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -16,6 +16,8 @@ import org.signal.core.util.BreakIteratorCompat; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.conversation.MessageSendType; import org.thoughtcrime.securesms.conversation.colors.ChatColors; @@ -32,10 +34,12 @@ import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryBackgroundColors; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; +import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideFactory; import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.sms.MessageSender; @@ -48,9 +52,11 @@ import org.thoughtcrime.securesms.util.MessageUtil; import org.thoughtcrime.securesms.util.Util; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -257,9 +263,18 @@ public final class MultiShareSender { } else { List storySupportedSlides = slideDeck.getSlides() .stream() + .flatMap(slide -> { + if (slide instanceof VideoSlide) { + return expandToClips(context, (VideoSlide) slide).stream(); + } else { + return java.util.stream.Stream.of(slide); + } + }) .filter(it -> MediaUtil.isStorySupportedType(it.getContentType())) .collect(Collectors.toList()); + // For each video slide, we want to convert it into a media, then clip it, and then transform it BACK into a slide. + for (final Slide slide : storySupportedSlides) { SlideDeck singletonDeck = new SlideDeck(); singletonDeck.addSlide(slide); @@ -319,6 +334,20 @@ public final class MultiShareSender { } } + private static Collection expandToClips(@NonNull Context context, @NonNull VideoSlide videoSlide) { + long duration = Stories.MediaTransform.getVideoDuration(Objects.requireNonNull(videoSlide.getUri())); + if (duration > Stories.MAX_VIDEO_DURATION_MILLIS) { + return Stories.MediaTransform.clipMediaToStoryDuration(Stories.MediaTransform.videoSlideToMedia(videoSlide, duration)) + .stream() + .map(media -> Stories.MediaTransform.mediaToVideoSlide(context, media)) + .collect(Collectors.toList()); + } else if (duration == 0L) { + return Collections.emptyList(); + } else { + return Collections.singletonList(videoSlide); + } + } + private static void sendTextMessage(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs, @NonNull Recipient recipient, diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index d3195c9073..bed0a14c5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -419,7 +419,7 @@ public class MessageSender { * @return A result if the attachment was enqueued, or null if it failed to enqueue or shouldn't * be enqueued (like in the case of a local self-send). */ - public static @Nullable PreUploadResult preUploadPushAttachment(@NonNull Context context, @NonNull Attachment attachment, @Nullable Recipient recipient) { + public static @Nullable PreUploadResult preUploadPushAttachment(@NonNull Context context, @NonNull Attachment attachment, @Nullable Recipient recipient, boolean isStoryClip) { if (isLocalSelfSend(context, recipient, false)) { return null; } @@ -439,7 +439,7 @@ public class MessageSender { .then(uploadJob) .enqueue(); - return new PreUploadResult(databaseAttachment.getAttachmentId(), Arrays.asList(compressionJob.getId(), resumableUploadSpecJob.getId(), uploadJob.getId())); + return new PreUploadResult(isStoryClip, databaseAttachment.getAttachmentId(), Arrays.asList(compressionJob.getId(), resumableUploadSpecJob.getId(), uploadJob.getId())); } catch (MmsException e) { Log.w(TAG, "preUploadPushAttachment() - Failed to upload!", e); return null; @@ -727,17 +727,24 @@ public class MessageSender { } public static class PreUploadResult implements Parcelable { - private final AttachmentId attachmentId; + private final boolean isVideo; + private final AttachmentId attachmentId; private final Collection jobIds; - PreUploadResult(@NonNull AttachmentId attachmentId, @NonNull Collection jobIds) { + PreUploadResult(boolean isVideo, @NonNull AttachmentId attachmentId, @NonNull Collection jobIds) { + this.isVideo = isVideo; this.attachmentId = attachmentId; this.jobIds = jobIds; } private PreUploadResult(Parcel in) { this.attachmentId = in.readParcelable(AttachmentId.class.getClassLoader()); - this.jobIds = ParcelUtil.readStringCollection(in); + this.jobIds = ParcelUtil.readStringCollection(in); + this.isVideo = ParcelUtil.readBoolean(in); + } + + public boolean isVideo() { + return isVideo; } public @NonNull AttachmentId getAttachmentId() { @@ -769,6 +776,7 @@ public class MessageSender { public void writeToParcel(Parcel dest, int flags) { dest.writeParcelable(attachmentId, flags); ParcelUtil.writeStringCollection(dest, jobIds); + ParcelUtil.writeBoolean(dest, isVideo); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt index 120fcfbebe..9040a92f7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt @@ -1,9 +1,18 @@ package org.thoughtcrime.securesms.stories +import android.content.Context +import android.net.Uri import androidx.annotation.WorkerThread import androidx.fragment.app.FragmentManager +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.ThreadUtil +import org.signal.core.util.isAbsent +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.HeaderAction import org.thoughtcrime.securesms.database.AttachmentDatabase @@ -13,8 +22,12 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet +import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage +import org.thoughtcrime.securesms.mms.SentMediaQuality +import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientUtil @@ -22,8 +35,13 @@ import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.hasLinkPreview +import java.util.Optional +import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.min object Stories { @@ -113,4 +131,248 @@ object Stories { ) } } + + object MediaTransform { + + private val TAG = Log.tag(MediaTransform::class.java) + + /** + * Describes what needs to be done in order to send a given piece of content. + * This is what will bubble up to the sending logic. + */ + enum class SendRequirements { + /** + * Don't need to do anything. + */ + VALID_DURATION, + + /** + * The media needs to be clipped and clipping is available. + */ + REQUIRES_CLIP, + + /** + * Either clipping isn't available or the given media has an invalid duration. + */ + CAN_NOT_SEND + } + + /** + * Describes a duration for a given piece of content. + */ + private sealed class DurationResult { + /** + * Valid to send as-is to a story. + */ + data class ValidDuration(val duration: Long) : DurationResult() + + /** + * Invalid to send as-is but can be clipped. + */ + data class InvalidDuration(val duration: Long) : DurationResult() + + /** + * Invalid to send, due to failure to get duration + */ + object CanNotGetDuration : DurationResult() + + /** + * Valid to send because the content does not have a duration. + */ + object None : DurationResult() + } + + @JvmStatic + @WorkerThread + fun getSendRequirements(media: Media): SendRequirements { + return when (getContentDuration(media)) { + is DurationResult.ValidDuration -> SendRequirements.VALID_DURATION + is DurationResult.InvalidDuration -> { + if (canClipMedia(media)) { + SendRequirements.REQUIRES_CLIP + } else { + SendRequirements.CAN_NOT_SEND + } + } + is DurationResult.CanNotGetDuration -> SendRequirements.CAN_NOT_SEND + is DurationResult.None -> SendRequirements.VALID_DURATION + } + } + + @JvmStatic + @WorkerThread + fun getSendRequirements(media: List): SendRequirements { + return media + .map { getSendRequirements(it) } + .fold(SendRequirements.VALID_DURATION) { left, right -> + if (left == SendRequirements.CAN_NOT_SEND || right == SendRequirements.CAN_NOT_SEND) { + SendRequirements.CAN_NOT_SEND + } else if (left == SendRequirements.REQUIRES_CLIP || right == SendRequirements.REQUIRES_CLIP) { + SendRequirements.REQUIRES_CLIP + } else { + SendRequirements.VALID_DURATION + } + } + } + + private fun canClipMedia(media: Media): Boolean { + return MediaUtil.isVideo(media.mimeType) && MediaConstraints.isVideoTranscodeAvailable() + } + + private fun getContentDuration(media: Media): DurationResult { + return if (MediaUtil.isVideo(media.mimeType)) { + val mediaDuration = if (media.duration == 0L && media.transformProperties.isAbsent()) { + getVideoDuration(media.uri) + } else if (media.transformProperties.map { it.isVideoTrim }.orElse(false)) { + TimeUnit.MICROSECONDS.toMillis(media.transformProperties.get().videoTrimEndTimeUs - media.transformProperties.get().videoTrimStartTimeUs) + } else { + media.duration + } + + return if (mediaDuration <= 0L) { + DurationResult.CanNotGetDuration + } else if (mediaDuration > MAX_VIDEO_DURATION_MILLIS) { + DurationResult.InvalidDuration(mediaDuration) + } else { + DurationResult.ValidDuration(mediaDuration) + } + } else { + DurationResult.None + } + } + + /** + * Utilizes ExoPlayer to ascertain the duration of the video at the given URI. It is the burden of + * the caller to ensure that the passed URI points to a video. This function must not be called from + * main, as it blocks on the calling thread and waits for some video player work to happen on the main + * thread. + */ + @JvmStatic + @WorkerThread + fun getVideoDuration(uri: Uri): Long { + var duration = 0L + var player: SimpleExoPlayer? = null + val countDownLatch = CountDownLatch(1) + ThreadUtil.runOnMainSync { + val mainThreadPlayer = ApplicationDependencies.getExoPlayerPool().get("stories_duration_check") + if (mainThreadPlayer == null) { + Log.w(TAG, "Could not get a player from the pool, so we cannot get the length of the video.") + countDownLatch.countDown() + } else { + mainThreadPlayer.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == 3) { + duration = mainThreadPlayer.duration + countDownLatch.countDown() + } + } + + override fun onPlayerError(error: PlaybackException) { + countDownLatch.countDown() + } + }) + + mainThreadPlayer.setMediaItem(MediaItem.fromUri(uri)) + mainThreadPlayer.prepare() + + player = mainThreadPlayer + } + } + + countDownLatch.await() + + ThreadUtil.runOnMainSync { + val mainThreadPlayer = player + if (mainThreadPlayer != null) { + ApplicationDependencies.getExoPlayerPool().pool(mainThreadPlayer) + } + } + + return max(duration, 0L) + } + + /** + * Takes a given piece of media and cuts it into 30 second chunks. It is assumed that the media handed in requires clipping. + * Callers can utilize canClipMedia to determine if the given media can and should be clipped. + */ + @JvmStatic + fun clipMediaToStoryDuration(media: Media): List { + val storyDurationUs = TimeUnit.MILLISECONDS.toMicros(MAX_VIDEO_DURATION_MILLIS) + val startOffsetUs = media.transformProperties.map { it.videoTrimStartTimeUs }.orElse(0L) + val endOffsetUs = media.transformProperties.map { it.videoTrimEndTimeUs }.orElse(TimeUnit.MILLISECONDS.toMicros(media.duration)) + val durationUs = endOffsetUs - startOffsetUs + + if (durationUs <= 0L) { + return emptyList() + } + + val clipCount = (durationUs / storyDurationUs) + (if (durationUs.mod(storyDurationUs) == 0L) 0L else 1L) + return (0 until clipCount).map { clipIndex -> + val startTimeUs = clipIndex * storyDurationUs + startOffsetUs + val endTimeUs = min(startTimeUs + storyDurationUs, endOffsetUs) + + if (startTimeUs > endTimeUs) { + error("Illegal clip: $startTimeUs > $endTimeUs for clip $clipIndex") + } + + AttachmentDatabase.TransformProperties(false, true, startTimeUs, endTimeUs, SentMediaQuality.STANDARD.code) + }.map { transformMedia(media, it) } + } + + private fun transformMedia(media: Media, transformProperties: AttachmentDatabase.TransformProperties): Media { + return Media( + media.uri, + media.mimeType, + media.date, + media.width, + media.height, + media.size, + media.duration, + media.isBorderless, + media.isVideoGif, + media.bucketId, + media.caption, + Optional.of(transformProperties) + ) + } + + /** + * Convenience method for transforming a Media into a VideoSlide + */ + @JvmStatic + fun mediaToVideoSlide(context: Context, media: Media): VideoSlide { + return VideoSlide( + context, + media.uri, + media.size, + media.isVideoGif, + media.width, + media.height, + media.caption.orElse(null), + media.transformProperties.orElse(null) + ) + } + + /** + * Convenience method for transforming a VideoSlide into a Media with the + * specified duration. + */ + @JvmStatic + fun videoSlideToMedia(videoSlide: VideoSlide, duration: Long): Media { + return Media( + videoSlide.uri!!, + videoSlide.contentType, + System.currentTimeMillis(), + 0, + 0, + videoSlide.fileSize, + duration, + videoSlide.isBorderless, + videoSlide.isVideoGif, + Optional.empty(), + videoSlide.caption, + Optional.empty() + ) + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e268edcc7..8d53658279 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4086,6 +4086,10 @@ Share with Add a message Faster forwards + + Videos will be trimmed to 30s clips and sent as multiple Stories. + + Videos sent to Stories can\'t be longer than 30s. Forwarded messages are now sent immediately. Send %1$d message