Implement video length enforcement for Stories.

This commit is contained in:
Alex Hart 2022-06-21 16:05:52 -03:00 committed by Cody Henthorne
parent 2c3d8337c3
commit 6a385c7a22
26 changed files with 597 additions and 108 deletions

View file

@ -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<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, onClick) {
private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: StoryClickListener) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(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<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, onClick) {
private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: RecipientClickListener) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(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<T : MappingModel<T>, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
private abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (View, D, Boolean) -> Unit) : MappingViewHolder<T>(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

View file

@ -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<ContactSearchKey>) -> Set<ContactSearchKey> = { _, s -> s }
) {
private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository())).get(ContactSearchViewModel::class.java)
init {
val adapter = PagingMappingAdapter<ContactSearchKey>()
recyclerView.adapter = adapter
@ -54,7 +57,7 @@ class ContactSearchMediator(
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {
viewModel.setKeysSelected(keys)
viewModel.setKeysSelected(contactSelectionPreFilter(null, keys))
}
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
@ -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)))
}
}
}

View file

@ -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

View file

@ -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<Callback>()?.canSendMediaToStories() ?: true
override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? {
return findListener<Callback>()?.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
}
}

View file

@ -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<Callback>().getStorySendRequirements() ?: viewModel.snapshot.storySendRequirements
}
private fun filterContacts(view: View?, contactSet: Set<ContactSearchKey>): Set<ContactSearchKey> {
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<SearchConfigurationProvider>()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build {
query = contactSearchState.query
@ -417,7 +466,7 @@ class MultiselectForwardFragment :
}
private fun isSelectedMediaValidForStories(): Boolean {
return requireListener<Callback>().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 {

View file

@ -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<Callback>()?.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<Callback>()?.canSendMediaToStories() ?: true
}
interface Callback {
fun onFinishForwardAction() = Unit
fun canSendMediaToStories(): Boolean = true
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null
}
}

View file

@ -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<MultiShareArgs>): Single<Stories.MediaTransform.SendRequirements> {
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<RecipientId>): Single<Boolean> {
if (!recipientId.isPresent) {
return Single.just(true)

View file

@ -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()

View file

@ -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<MultiselectForwardState> = 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<ContactSearchKey>) {
if (SignalStore.tooltips().showMultiForwardDialog()) {

View file

@ -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);
}
}
}

View file

@ -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());

View file

@ -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<String> = mutableListOf(MANUAL_FEATURE_DISABLE, USER_HAS_ADDED_TO_A_STORY)
override fun getKeysToIncludeInBackup(): MutableList<String> = 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<StorySend> = getList(LATEST_STORY_SENDS, StorySendSerializer)

View file

@ -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);

View file

@ -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()
}
}

View file

@ -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<ContactSearchKey.RecipientSearchKey>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
val broadcastMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList(contacts.size)
val storyMessages: MutableMap<PreUploadResult, MutableList<OutgoingSecureMediaMessage>> = mutableMapOf()
val distributionListSentTimestamps: MutableMap<PreUploadResult, Long> = mutableMapOf()
private fun sendMessages(
contacts: List<ContactSearchKey.RecipientSearchKey>,
body: String,
preUploadResults: Collection<PreUploadResult>,
mentions: List<Mention>,
isViewOnce: Boolean,
storyClips: List<Media>
) {
val nonStoryMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList(contacts.size)
val storyPreUploadMessages: MutableMap<PreUploadResult, MutableList<OutgoingSecureMediaMessage>> = mutableMapOf()
val storyClipMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList()
val distributionListPreUploadSentTimestamps: MutableMap<PreUploadResult, Long> = mutableMapOf()
val distributionListStoryClipsSentTimestamps: MutableMap<Media, Long> = 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)
}
}
}

View file

@ -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<Uri, Any> = 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) {

View file

@ -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<List<Media>> = BehaviorSubject.create()
private val store: Store<MediaSelectionState> = 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<Media>) {
val newSelectionList: List<Media> = linkedSetOf<Media>().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<Media> = savedInstanceState.getParcelableArrayList(STATE_SELECTION) ?: emptyList()
val focused: Media? = savedInstanceState.getParcelable(STATE_FOCUSED)
@ -362,6 +379,8 @@ class MediaSelectionViewModel(
val editorStates: List<Bundle> = savedInstanceState.getParcelableArrayList(STATE_EDITORS) ?: emptyList()
val editorStateMap = editorStates.associate { it.toAssociation() }
selectedMediaSubject.onNext(selection)
store.update { state ->
state.copy(
selectedMedia = selection,

View file

@ -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<Media>, 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<Media>, mediaConstraints: MediaConstraints, isStory: Boolean): List<Media> {
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
}
}

View file

@ -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(

View file

@ -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) })

View file

@ -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"

View file

@ -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;

View file

@ -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<Slide> 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<Slide> 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,

View file

@ -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<String> jobIds;
PreUploadResult(@NonNull AttachmentId attachmentId, @NonNull Collection<String> jobIds) {
PreUploadResult(boolean isVideo, @NonNull AttachmentId attachmentId, @NonNull Collection<String> 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);
}
}

View file

@ -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<Media>): 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<Media> {
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()
)
}
}
}

View file

@ -4086,6 +4086,10 @@
<string name="MultiselectForwardFragment__share_with">Share with</string>
<string name="MultiselectForwardFragment__add_a_message">Add a message</string>
<string name="MultiselectForwardFragment__faster_forwards">Faster forwards</string>
<!-- Displayed when user selects a video that will be clipped before sharing to a story -->
<string name="MultiselectForwardFragment__videos_will_be_trimmed">Videos will be trimmed to 30s clips and sent as multiple Stories.</string>
<!-- Displayed when user selects a video that cannot be sent as a story -->
<string name="MultiselectForwardFragment__videos_sent_to_stories_cant">Videos sent to Stories can\'t be longer than 30s.</string>
<string name="MultiselectForwardFragment__forwarded_messages_are_now">Forwarded messages are now sent immediately.</string>
<plurals name="MultiselectForwardFragment_send_d_messages">
<item quantity="one">Send %1$d message</item>