Implement video length enforcement for Stories.
This commit is contained in:
parent
2c3d8337c3
commit
6a385c7a22
26 changed files with 597 additions and 108 deletions
|
@ -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
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) })
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue