mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-29 13:16:01 +01:00
Refresh media selection and sending flow with a shiny new UX.
This commit is contained in:
committed by
Greyson Parrelli
parent
a940487611
commit
664d6475d9
@@ -57,6 +57,16 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelEditing() {
|
||||
Navigation.findNavController(requireView()).popBackStack()
|
||||
}
|
||||
|
||||
override fun onMainImageLoaded() {
|
||||
}
|
||||
|
||||
override fun onMainImageFailedToLoad() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
|
||||
@@ -149,7 +148,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val media: Media = Objects.requireNonNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
|
||||
val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
|
||||
viewModel.onAvatarPhotoSelectionCompleted(media)
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
@@ -195,23 +194,23 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
}
|
||||
}
|
||||
|
||||
fun openPhotoEditor(photo: Avatar.Photo) {
|
||||
private fun openPhotoEditor(photo: Avatar.Photo) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
|
||||
}
|
||||
|
||||
fun openVectorEditor(vector: Avatar.Vector) {
|
||||
private fun openVectorEditor(vector: Avatar.Vector) {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
|
||||
}
|
||||
|
||||
fun openTextEditor(text: Avatar.Text?) {
|
||||
private fun openTextEditor(text: Avatar.Text?) {
|
||||
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
|
||||
}
|
||||
|
||||
fun openCameraCapture() {
|
||||
private fun openCameraCapture() {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
@@ -226,7 +225,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
.execute()
|
||||
}
|
||||
|
||||
fun openGallery() {
|
||||
private fun openGallery() {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Fullscreen Dialog Fragment which will dismiss itself when the keyboard is closed
|
||||
*/
|
||||
abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
DialogFragment(contentLayoutId),
|
||||
KeyboardAwareLinearLayout.OnKeyboardShownListener,
|
||||
KeyboardAwareLinearLayout.OnKeyboardHiddenListener {
|
||||
|
||||
private var hasShown = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
|
||||
dialog.window?.setDimAmount(0f)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
hasShown = false
|
||||
|
||||
val view = super.onCreateView(inflater, container, savedInstanceState)
|
||||
return if (view is KeyboardAwareLinearLayout) {
|
||||
view.addOnKeyboardShownListener(this)
|
||||
view.addOnKeyboardHiddenListener(this)
|
||||
view
|
||||
} else {
|
||||
throw IllegalStateException("Expected parent of view hierarchy to be keyboard aware.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyboardShown() {
|
||||
hasShown = true
|
||||
}
|
||||
|
||||
override fun onKeyboardHidden() {
|
||||
if (hasShown) {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ContextThemeWrapper;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -20,6 +21,8 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
private static final String TAG = Log.tag(MediaKeyboard.class);
|
||||
@@ -40,6 +43,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public void setFragmentManager(@NonNull FragmentManager fragmentManager) {
|
||||
this.fragmentManager = fragmentManager;
|
||||
}
|
||||
|
||||
public void setKeyboardListener(@Nullable MediaKeyboardListener listener) {
|
||||
this.keyboardListener = listener;
|
||||
}
|
||||
@@ -125,13 +132,32 @@ public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
private void initView() {
|
||||
if (!isInitialised) {
|
||||
|
||||
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
|
||||
|
||||
if (fragmentManager == null) {
|
||||
FragmentActivity activity = resolveActivity(getContext());
|
||||
fragmentManager = activity.getSupportFragmentManager();
|
||||
}
|
||||
|
||||
keyboardPagerFragment = new KeyboardPagerFragment();
|
||||
fragmentManager.beginTransaction()
|
||||
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment)
|
||||
.commitNowAllowingStateLoss();
|
||||
|
||||
keyboardState = State.NORMAL;
|
||||
latestKeyboardHeight = -1;
|
||||
isInitialised = true;
|
||||
fragmentManager = ((FragmentActivity) getContext()).getSupportFragmentManager();
|
||||
keyboardPagerFragment = (KeyboardPagerFragment) fragmentManager.findFragmentById(R.id.media_keyboard_fragment_container);
|
||||
}
|
||||
}
|
||||
|
||||
private static FragmentActivity resolveActivity(@Nullable Context context) {
|
||||
if (context instanceof FragmentActivity) {
|
||||
return (FragmentActivity) context;
|
||||
} else if (context instanceof ContextThemeWrapper) {
|
||||
return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
|
||||
} else {
|
||||
throw new IllegalStateException("Could not locate FragmentActivity");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,8 +205,8 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel;
|
||||
import org.thoughtcrime.securesms.maps.PlacePickerActivity;
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
|
||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity;
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestState;
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel;
|
||||
@@ -741,7 +741,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
initializeSecurity(isSecureText, isDefaultSms);
|
||||
break;
|
||||
case MEDIA_SENDER:
|
||||
MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivity.EXTRA_RESULT);
|
||||
MediaSendActivityResult result = MediaSendActivityResult.fromData(data);
|
||||
|
||||
if (!Objects.equals(result.getRecipientId(), recipient.getId())) {
|
||||
Log.w(TAG, "Result's recipientId did not match ours! Result: " + result.getRecipientId() + ", Activity: " + recipient.getId());
|
||||
@@ -1144,7 +1144,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
@Override
|
||||
public void onAttachmentMediaClicked(@NonNull Media media) {
|
||||
linkPreviewViewModel.onUserCancel();
|
||||
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
|
||||
startActivityForResult(MediaSelectionActivity.editor(ConversationActivity.this, sendButton.getSelectedTransport(), Collections.singletonList(media), recipient.getId(), composeText.getTextTrimmed()), MEDIA_SENDER);
|
||||
container.hideCurrentInput(composeText);
|
||||
}
|
||||
|
||||
@@ -1152,7 +1152,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
public void onAttachmentSelectorClicked(@NonNull AttachmentKeyboardButton button) {
|
||||
switch (button) {
|
||||
case GALLERY:
|
||||
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
|
||||
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport(), inputPanel.getQuote().isPresent());
|
||||
break;
|
||||
case FILE:
|
||||
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
|
||||
@@ -1607,7 +1607,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
if (!Util.isEmpty(mediaList)) {
|
||||
Log.d(TAG, "Handling shared Media.");
|
||||
Intent sendIntent = MediaSendActivity.buildEditorIntent(this, mediaList, recipient.get(), draftText, sendButton.getSelectedTransport());
|
||||
Intent sendIntent = MediaSelectionActivity.editor(this, sendButton.getSelectedTransport(), mediaList, recipient.getId(), draftText);
|
||||
startActivityForResult(sendIntent, MEDIA_SENDER);
|
||||
return new SettableFuture<>(false);
|
||||
}
|
||||
@@ -2563,7 +2563,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
Media media = new Media(uri, mimeType, 0, width, height, 0, 0, borderless, videoGif, Optional.absent(), Optional.absent(), Optional.absent());
|
||||
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
|
||||
startActivityForResult(MediaSelectionActivity.editor(ConversationActivity.this, sendButton.getSelectedTransport(), Collections.singletonList(media), recipient.getId(), composeText.getTextTrimmed()), MEDIA_SENDER);
|
||||
return new SettableFuture<>(false);
|
||||
} else {
|
||||
return attachmentManager.setMedia(glideRequests, uri, mediaType, getCurrentMediaConstraints(), width, height);
|
||||
@@ -3309,7 +3309,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private void sendSticker(@NonNull StickerLocator stickerLocator, @NonNull String contentType, @NonNull Uri uri, long size, boolean clearCompose) {
|
||||
if (sendButton.getSelectedTransport().isSms()) {
|
||||
Media media = new Media(uri, contentType, System.currentTimeMillis(), StickerSlide.WIDTH, StickerSlide.HEIGHT, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent());
|
||||
Intent intent = MediaSendActivity.buildEditorIntent(this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
|
||||
Intent intent = MediaSelectionActivity.editor(this, sendButton.getSelectedTransport(), Collections.singletonList(media), recipient.getId(), composeText.getTextTrimmed());
|
||||
startActivityForResult(intent, MEDIA_SENDER);
|
||||
return;
|
||||
}
|
||||
@@ -3448,7 +3448,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
|
||||
.onAllGranted(() -> {
|
||||
composeText.clearFocus();
|
||||
startActivityForResult(MediaSendActivity.buildCameraIntent(ConversationActivity.this, recipient.get(), sendButton.getSelectedTransport()), MEDIA_SENDER);
|
||||
startActivityForResult(MediaSelectionActivity.camera(ConversationActivity.this, sendButton.getSelectedTransport(), recipient.getId(), inputPanel.getQuote().isPresent()), MEDIA_SENDER);
|
||||
overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary);
|
||||
})
|
||||
.onAnyDenied(() -> Toast.makeText(ConversationActivity.this, R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show())
|
||||
|
||||
@@ -9,10 +9,12 @@ import android.view.ViewGroup
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
@@ -45,6 +47,7 @@ import java.util.function.Consumer
|
||||
|
||||
private const val ARG_MULTISHARE_ARGS = "multiselect.forward.fragment.arg.multishare.args"
|
||||
private const val ARG_CAN_SEND_TO_NON_PUSH = "multiselect.forward.fragment.arg.can.send.to.non.push"
|
||||
private const val ARG_TITLE = "multiselect.forward.fragment.title"
|
||||
private val TAG = Log.tag(MultiselectForwardFragment::class.java)
|
||||
|
||||
class MultiselectForwardFragment :
|
||||
@@ -61,7 +64,8 @@ class MultiselectForwardFragment :
|
||||
private lateinit var selectionFragment: ContactSelectionListFragment
|
||||
private lateinit var contactFilterView: ContactFilterView
|
||||
private lateinit var addMessage: EditText
|
||||
private lateinit var callback: Callback
|
||||
|
||||
private var callback: Callback? = null
|
||||
|
||||
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
|
||||
|
||||
@@ -96,7 +100,7 @@ class MultiselectForwardFragment :
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
callback = requireNotNull(findListener())
|
||||
callback = findListener()
|
||||
disposables.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment
|
||||
@@ -117,12 +121,15 @@ class MultiselectForwardFragment :
|
||||
}
|
||||
}
|
||||
|
||||
val title: TextView = view.findViewById(R.id.title)
|
||||
val container = view.parent.parent.parent as FrameLayout
|
||||
val bottomBar = LayoutInflater.from(requireContext()).inflate(R.layout.multiselect_forward_fragment_bottom_bar, container, false)
|
||||
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
|
||||
val shareSelectionAdapter = ShareSelectionAdapter()
|
||||
val sendButton: View = bottomBar.findViewById(R.id.share_confirm)
|
||||
|
||||
title.setText(requireArguments().getInt(ARG_TITLE))
|
||||
|
||||
addMessage = bottomBar.findViewById(R.id.add_message)
|
||||
|
||||
sendButton.setOnClickListener {
|
||||
@@ -162,6 +169,7 @@ class MultiselectForwardFragment :
|
||||
MultiselectForwardState.Stage.SomeFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
|
||||
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
|
||||
MultiselectForwardState.Stage.Success -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
|
||||
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithResult(it.stage.recipients)
|
||||
}
|
||||
|
||||
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
|
||||
@@ -170,6 +178,8 @@ class MultiselectForwardFragment :
|
||||
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
|
||||
selectionFragment.setRecyclerViewPaddingBottom(bottom - top)
|
||||
}
|
||||
|
||||
addMessage.visible = getMultiShareArgs().isNotEmpty()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -222,18 +232,30 @@ class MultiselectForwardFragment :
|
||||
private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) {
|
||||
val argCount = getMessageCount()
|
||||
|
||||
callback.onFinishForwardAction()
|
||||
callback?.onFinishForwardAction()
|
||||
dismissibleDialog?.dismiss()
|
||||
Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
private fun dismissWithResult(recipientIds: List<RecipientId>) {
|
||||
callback?.onFinishForwardAction()
|
||||
dismissibleDialog?.dismiss()
|
||||
setFragmentResult(
|
||||
RESULT_SELECTION,
|
||||
Bundle().apply {
|
||||
putParcelableArrayList(RESULT_SELECTION_RECIPIENTS, ArrayList(recipientIds))
|
||||
}
|
||||
)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
private fun getMessageCount(): Int = getMultiShareArgs().size + if (addMessage.text.isNotEmpty()) 1 else 0
|
||||
|
||||
private fun handleMessageExpired() {
|
||||
dismissAllowingStateLoss()
|
||||
|
||||
callback.onFinishForwardAction()
|
||||
callback?.onFinishForwardAction()
|
||||
dismissibleDialog?.dismiss()
|
||||
Toast.makeText(requireContext(), resources.getQuantityString(R.plurals.MultiselectForwardFragment__couldnt_forward_messages, getMultiShareArgs().size), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
@@ -299,6 +321,10 @@ class MultiselectForwardFragment :
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val RESULT_SELECTION = "result_selection"
|
||||
const val RESULT_SELECTION_RECIPIENTS = "result_selection_recipients"
|
||||
|
||||
@JvmStatic
|
||||
fun show(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
|
||||
val fragment = MultiselectForwardFragment()
|
||||
@@ -306,6 +332,7 @@ class MultiselectForwardFragment :
|
||||
fragment.arguments = Bundle().apply {
|
||||
putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs))
|
||||
putBoolean(ARG_CAN_SEND_TO_NON_PUSH, multiselectForwardFragmentArgs.canSendToNonPush)
|
||||
putInt(ARG_TITLE, multiselectForwardFragmentArgs.title)
|
||||
}
|
||||
|
||||
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect
|
||||
@@ -16,9 +18,17 @@ import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* Arguments for the MultiselectForwardFragment.
|
||||
*
|
||||
* @param canSendToNonPush Whether non-push recipients will be displayed
|
||||
* @param multiShareArgs The items to forward. If this is an empty list, the fragment owner will be sent back a selected list of contacts.
|
||||
* @param title The title to display at the top of the sheet
|
||||
*/
|
||||
class MultiselectForwardFragmentArgs(
|
||||
val canSendToNonPush: Boolean,
|
||||
val multiShareArgs: List<MultiShareArgs>
|
||||
val multiShareArgs: List<MultiShareArgs> = listOf(),
|
||||
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sharing.ShareContact
|
||||
|
||||
data class MultiselectForwardState(
|
||||
@@ -16,5 +17,6 @@ data class MultiselectForwardState(
|
||||
object SomeFailed : Stage()
|
||||
object AllFailed : Stage()
|
||||
object Success : Stage()
|
||||
data class SelectionConfirmed(val recipients: List<RecipientId>) : Stage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +68,26 @@ class MultiselectForwardViewModel(
|
||||
|
||||
private fun performSend(additionalMessage: String) {
|
||||
store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) }
|
||||
repository.send(
|
||||
additionalMessage = additionalMessage,
|
||||
multiShareArgs = records,
|
||||
shareContacts = store.state.selectedContacts,
|
||||
MultiselectForwardRepository.MultiselectForwardResultHandlers(
|
||||
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
|
||||
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.AllFailed) } },
|
||||
onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SomeFailed) } }
|
||||
if (records.isEmpty()) {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
stage = MultiselectForwardState.Stage.SelectionConfirmed(
|
||||
state.selectedContacts.filter { it.recipientId.isPresent }.map { it.recipientId.get() }.distinct()
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
repository.send(
|
||||
additionalMessage = additionalMessage,
|
||||
multiShareArgs = records,
|
||||
shareContacts = store.state.selectedContacts,
|
||||
MultiselectForwardRepository.MultiselectForwardResultHandlers(
|
||||
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
|
||||
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.AllFailed) } },
|
||||
onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SomeFailed) } }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -106,7 +106,7 @@ import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone;
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController;
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder;
|
||||
@@ -269,7 +269,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24)
|
||||
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
|
||||
.onAllGranted(() -> startActivity(MediaSendActivity.buildCameraFirstIntent(requireActivity())))
|
||||
.onAllGranted(() -> startActivity(MediaSelectionActivity.camera(requireContext())))
|
||||
.onAnyDenied(() -> Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show())
|
||||
.execute();
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer;
|
||||
/**
|
||||
* Invisible {@link android.widget.EditText} that is used during in-image text editing.
|
||||
*/
|
||||
final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText {
|
||||
public final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText {
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING;
|
||||
|
||||
@@ -4,13 +4,19 @@ import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -22,6 +28,10 @@ import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* ImageEditorView
|
||||
@@ -67,6 +77,9 @@ public final class ImageEditorView extends FrameLayout {
|
||||
@Nullable
|
||||
private UndoRedoStackListener undoRedoStackListener;
|
||||
|
||||
@Nullable
|
||||
private DrawListener drawListener;
|
||||
|
||||
private final Matrix viewMatrix = new Matrix();
|
||||
private final RectF viewPort = Bounds.newFullBounds();
|
||||
private final RectF visibleViewPort = Bounds.newFullBounds();
|
||||
@@ -114,18 +127,13 @@ public final class ImageEditorView extends FrameLayout {
|
||||
return editText;
|
||||
}
|
||||
|
||||
public void startTextEditing(@NonNull EditorElement editorElement, boolean incognitoKeyboardEnabled, boolean selectAll) {
|
||||
public void startTextEditing(@NonNull EditorElement editorElement) {
|
||||
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
|
||||
editText.setIncognitoKeyboardEnabled(incognitoKeyboardEnabled);
|
||||
editText.setCurrentTextEditorElement(editorElement);
|
||||
if (selectAll) {
|
||||
editText.selectAll();
|
||||
}
|
||||
editText.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
private void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) {
|
||||
public void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) {
|
||||
getModel().zoomToTextElement(editorElement, textRenderer);
|
||||
}
|
||||
|
||||
@@ -138,9 +146,6 @@ public final class ImageEditorView extends FrameLayout {
|
||||
if (editText.getCurrentTextEntity() != null) {
|
||||
editText.setCurrentTextEditorElement(null);
|
||||
editText.hideKeyboard();
|
||||
if (tapListener != null) {
|
||||
tapListener.onEntityDown(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +237,7 @@ public final class ImageEditorView extends FrameLayout {
|
||||
moreThanOnePointerUsedInSession = false;
|
||||
model.pushUndoPoint();
|
||||
editSession = startEdit(inverse, point, selected);
|
||||
notifyStartIfInDraw();
|
||||
|
||||
if (tapListener != null && allowTaps()) {
|
||||
if (editSession != null) {
|
||||
@@ -276,7 +282,7 @@ public final class ImageEditorView extends FrameLayout {
|
||||
editSession = null;
|
||||
}
|
||||
if (editSession == null) {
|
||||
dragDropRelease();
|
||||
dragDropRelease(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -286,7 +292,7 @@ public final class ImageEditorView extends FrameLayout {
|
||||
if (editSession != null && event.getActionIndex() < 2) {
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
dragDropRelease();
|
||||
dragDropRelease(true);
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
if (newInverse != null) {
|
||||
@@ -301,7 +307,8 @@ public final class ImageEditorView extends FrameLayout {
|
||||
case MotionEvent.ACTION_UP: {
|
||||
if (editSession != null) {
|
||||
editSession.commit();
|
||||
dragDropRelease();
|
||||
dragDropRelease(false);
|
||||
notifyEndIfInDraw();
|
||||
|
||||
editSession = null;
|
||||
model.postEdit(moreThanOnePointerUsedInSession);
|
||||
@@ -317,6 +324,22 @@ public final class ImageEditorView extends FrameLayout {
|
||||
return super.onTouchEvent(event);
|
||||
}
|
||||
|
||||
private void notifyStartIfInDraw() {
|
||||
if (mode == Mode.Draw || mode == Mode.Blur) {
|
||||
if (drawListener != null) {
|
||||
drawListener.onDrawStarted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyEndIfInDraw() {
|
||||
if (mode == Mode.Draw || mode == Mode.Blur) {
|
||||
if (drawListener != null) {
|
||||
drawListener.onDrawEnded();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
|
||||
if (mode == Mode.Draw || mode == Mode.Blur) {
|
||||
return startADrawingSession(point);
|
||||
@@ -371,10 +394,10 @@ public final class ImageEditorView extends FrameLayout {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
private void dragDropRelease() {
|
||||
private void dragDropRelease(boolean stillTouching) {
|
||||
model.dragDropRelease();
|
||||
if (drawingChangedListener != null) {
|
||||
drawingChangedListener.onDrawingChanged();
|
||||
drawingChangedListener.onDrawingChanged(stillTouching);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +430,10 @@ public final class ImageEditorView extends FrameLayout {
|
||||
this.undoRedoStackListener = undoRedoStackListener;
|
||||
}
|
||||
|
||||
public void setDrawListener(@Nullable DrawListener drawListener) {
|
||||
this.drawListener = drawListener;
|
||||
}
|
||||
|
||||
public void setTapListener(TapListener tapListener) {
|
||||
this.tapListener = tapListener;
|
||||
}
|
||||
@@ -470,13 +497,18 @@ public final class ImageEditorView extends FrameLayout {
|
||||
}
|
||||
|
||||
public interface DrawingChangedListener {
|
||||
void onDrawingChanged();
|
||||
void onDrawingChanged(boolean stillTouching);
|
||||
}
|
||||
|
||||
public interface SizeChangedListener {
|
||||
void onSizeChanged(int newWidth, int newHeight);
|
||||
}
|
||||
|
||||
public interface DrawListener {
|
||||
void onDrawStarted();
|
||||
void onDrawEnded();
|
||||
}
|
||||
|
||||
public interface TapListener {
|
||||
|
||||
void onEntityDown(@Nullable EditorElement editorElement);
|
||||
|
||||
@@ -93,7 +93,9 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
}
|
||||
|
||||
public static EditorModel create() {
|
||||
return new EditorModel(EditingPurpose.IMAGE, 0, EditorElementHierarchy.create());
|
||||
EditorModel model = new EditorModel(EditingPurpose.IMAGE, 0, EditorElementHierarchy.create());
|
||||
model.setCropAspectLock(false);
|
||||
return model;
|
||||
}
|
||||
|
||||
public static EditorModel createForAvatarCapture() {
|
||||
@@ -193,6 +195,38 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
public void clearUndoStack() {
|
||||
EditorElement root = editorElementHierarchy.getRoot();
|
||||
EditorElement original = root;
|
||||
boolean cropping = isCropping();
|
||||
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
|
||||
boolean didPop = false;
|
||||
|
||||
while (stacks.canUndo(root)) {
|
||||
final EditorElement oldRootElement = root;
|
||||
final EditorElement popped = stacks.getUndoStack().pop(oldRootElement);
|
||||
|
||||
if (popped != null) {
|
||||
didPop = true;
|
||||
editorElementHierarchy = EditorElementHierarchy.create(popped);
|
||||
stacks.getRedoStack().tryPush(oldRootElement);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
root = editorElementHierarchy.getRoot();
|
||||
}
|
||||
|
||||
if (didPop) {
|
||||
restoreStateWithAnimations(original, editorElementHierarchy.getRoot(), invalidate, cropping);
|
||||
invalidate.run();
|
||||
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
|
||||
}
|
||||
|
||||
updateUndoRedoAvailableState(stacks);
|
||||
}
|
||||
|
||||
public void undo() {
|
||||
boolean cropping = isCropping();
|
||||
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
|
||||
@@ -491,7 +525,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
private static int compareRatios(@NonNull Point a, @NonNull Point b) {
|
||||
int smallA = Math.min(a.x, a.y);
|
||||
int largeA = Math.max(a.x, a.y);
|
||||
|
||||
|
||||
int smallB = Math.min(b.x, b.y);
|
||||
int largeB = Math.max(b.x, b.y);
|
||||
|
||||
@@ -751,7 +785,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
pushUndoPoint();
|
||||
hasPushedUndo = true;
|
||||
}
|
||||
|
||||
|
||||
mainImage.deleteChild(mainImage.getChild(i), invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* Renders the {@link color} outside of the {@link Bounds}.
|
||||
@@ -24,6 +25,9 @@ public final class InverseFillRenderer implements Renderer {
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.save();
|
||||
|
||||
path.reset();
|
||||
path.addRoundRect(Bounds.FULL_BOUNDS, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18), Path.Direction.CW);
|
||||
rendererContext.canvas.clipPath(path);
|
||||
rendererContext.canvas.drawColor(color);
|
||||
rendererContext.canvas.restore();
|
||||
@@ -32,11 +36,6 @@ public final class InverseFillRenderer implements Renderer {
|
||||
public InverseFillRenderer(@ColorInt int color) {
|
||||
this.color = color;
|
||||
path.toggleInverseFillType();
|
||||
path.moveTo(Bounds.LEFT, Bounds.TOP);
|
||||
path.lineTo(Bounds.RIGHT, Bounds.TOP);
|
||||
path.lineTo(Bounds.RIGHT, Bounds.BOTTOM);
|
||||
path.lineTo(Bounds.LEFT, Bounds.BOTTOM);
|
||||
path.close();
|
||||
}
|
||||
|
||||
private InverseFillRenderer(Parcel in) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
@@ -37,14 +36,9 @@ class EmojiKeyboardPageFragment : Fragment(R.layout.keyboard_pager_emoji_page_fr
|
||||
|
||||
private val categoryUpdateOnScroll = UpdateCategorySelectionOnScroll()
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
callback = context as Callback
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
callback = requireNotNull(findListener())
|
||||
|
||||
emojiPageView = view.findViewById(R.id.emoji_page_view)
|
||||
emojiPageView.initialize(this, this, true)
|
||||
emojiPageView.addOnScrollListener(categoryUpdateOnScroll)
|
||||
|
||||
@@ -13,21 +13,23 @@ import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.TransportOptions;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.util.Collections;
|
||||
|
||||
public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller {
|
||||
public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaGalleryFragment.Callbacks {
|
||||
|
||||
private static final Point AVATAR_DIMENSIONS = new Point(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS);
|
||||
|
||||
@@ -62,13 +64,10 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.avatar_selection_activity);
|
||||
|
||||
MediaSendViewModel viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
|
||||
viewModel.setTransport(TransportOptions.getPushTransportOption(this));
|
||||
|
||||
if (isGalleryFirst()) {
|
||||
onGalleryClicked();
|
||||
} else {
|
||||
onCameraSelected();
|
||||
onNavigateToCamera();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +114,9 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
|
||||
return;
|
||||
}
|
||||
|
||||
MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(this, null);
|
||||
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, fragment);
|
||||
MediaGalleryFragment fragment = new MediaGalleryFragment();
|
||||
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, fragment);
|
||||
|
||||
if (isCameraFirst()) {
|
||||
transaction.addToBackStack(null);
|
||||
@@ -136,6 +135,16 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
|
||||
throw new UnsupportedOperationException("Cannot select more than one photo");
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull LiveData<Optional<Media>> getMostRecentMediaItem() {
|
||||
return new DefaultValueLiveData<>(Optional.absent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MediaConstraints getMediaConstraints() {
|
||||
return MediaConstraints.getPushMediaConstraints();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTouchEventsNeeded(boolean needed) {
|
||||
}
|
||||
@@ -144,14 +153,6 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
|
||||
public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFolderSelected(@NonNull MediaFolder folder) {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), 1, false))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaSelected(@NonNull Media media) {
|
||||
currentMedia = media;
|
||||
@@ -163,25 +164,23 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraSelected() {
|
||||
if (isCameraFirst() && popToRoot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Fragment fragment = CameraFragment.newInstanceForAvatarCapture();
|
||||
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, fragment, IMAGE_CAPTURE);
|
||||
|
||||
if (isGalleryFirst()) {
|
||||
transaction.addToBackStack(null);
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
public void onDoneEditing() {
|
||||
handleSave();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDoneEditing() {
|
||||
handleSave();
|
||||
public void onCancelEditing() {
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMainImageLoaded() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMainImageFailedToLoad() {
|
||||
|
||||
}
|
||||
|
||||
public boolean popToRoot() {
|
||||
@@ -230,4 +229,46 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMultiselectEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaUnselected(@NonNull Media media) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectedMediaClicked(@NonNull Media media) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNavigateToCamera() {
|
||||
if (isCameraFirst() && popToRoot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Fragment fragment = CameraFragment.newInstanceForAvatarCapture();
|
||||
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, fragment, IMAGE_CAPTURE);
|
||||
|
||||
if (isGalleryFirst()) {
|
||||
transaction.addToBackStack(null);
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSubmit() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolbarNavigationClicked() {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Matrix;
|
||||
@@ -22,11 +24,9 @@ import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.RotateAnimation;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.MultiTransformation;
|
||||
@@ -38,6 +38,8 @@ import com.bumptech.glide.request.transition.Transition;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
@@ -65,7 +67,6 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
private Controller controller;
|
||||
private OrderEnforcer<Stage> orderEnforcer;
|
||||
private Camera1Controller.Properties properties;
|
||||
private MediaSendViewModel viewModel;
|
||||
|
||||
public static Camera1Fragment newInstance() {
|
||||
return new Camera1Fragment();
|
||||
@@ -74,8 +75,15 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (!(getActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement the Controller interface.");
|
||||
|
||||
if (getActivity() instanceof Controller) {
|
||||
this.controller = (Controller) getActivity();
|
||||
} else if (getParentFragment() instanceof Controller) {
|
||||
this.controller = (Controller) getParentFragment();
|
||||
}
|
||||
|
||||
if (controller == null) {
|
||||
throw new IllegalStateException("Parent must implement controller interface.");
|
||||
}
|
||||
|
||||
WindowManager windowManager = ServiceUtil.getWindowManager(getActivity());
|
||||
@@ -84,10 +92,8 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
|
||||
display.getSize(displaySize);
|
||||
|
||||
controller = (Controller) getActivity();
|
||||
camera = new Camera1Controller(TextSecurePreferences.getDirectCaptureCameraId(getContext()), displaySize.x, displaySize.y, this);
|
||||
orderEnforcer = new OrderEnforcer<>(Stage.SURFACE_AVAILABLE, Stage.CAMERA_PROPERTIES_AVAILABLE);
|
||||
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -104,6 +110,8 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
cameraPreview = view.findViewById(R.id.camera_preview);
|
||||
controlsContainer = view.findViewById(R.id.camera_controls_container);
|
||||
|
||||
View cameraParent = view.findViewById(R.id.camera_preview_parent);
|
||||
|
||||
onOrientationChanged(getResources().getConfiguration().orientation);
|
||||
|
||||
cameraPreview.setSurfaceTextureListener(this);
|
||||
@@ -111,14 +119,29 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
GestureDetector gestureDetector = new GestureDetector(flipGestureListener);
|
||||
cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
|
||||
|
||||
viewModel.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
|
||||
viewModel.getHudState().observe(getViewLifecycleOwner(), this::presentHud);
|
||||
controller.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
|
||||
|
||||
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
// Let's assume portrait for now, so 9:16
|
||||
float aspectRatio = 9f / 16f;
|
||||
float width = right - left;
|
||||
float height = Math.min((1f / aspectRatio) * width, bottom - top);
|
||||
|
||||
ViewGroup.LayoutParams params = cameraParent.getLayoutParams();
|
||||
|
||||
// If there's a mismatch...
|
||||
if (params.height != (int) height) {
|
||||
params.width = (int) width;
|
||||
params.height = (int) height;
|
||||
|
||||
cameraParent.setLayoutParams(params);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
viewModel.onCameraStarted();
|
||||
camera.initialize();
|
||||
|
||||
if (cameraPreview.isAvailable()) {
|
||||
@@ -144,6 +167,35 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
orderEnforcer.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fadeOutControls(@NonNull Runnable onEndAction) {
|
||||
controlsContainer.setEnabled(false);
|
||||
controlsContainer.animate()
|
||||
.setDuration(250)
|
||||
.alpha(0f)
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
controlsContainer.setEnabled(true);
|
||||
onEndAction.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fadeInControls() {
|
||||
controlsContainer.setEnabled(false);
|
||||
controlsContainer.animate()
|
||||
.setDuration(250)
|
||||
.alpha(1f)
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
controlsContainer.setEnabled(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
@@ -203,15 +255,13 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
}
|
||||
}
|
||||
|
||||
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
|
||||
if (state == null) return;
|
||||
@Override
|
||||
public void presentHud(int selectedMediaCount) {
|
||||
MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button);
|
||||
|
||||
View countButton = controlsContainer.findViewById(R.id.camera_count_button);
|
||||
TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text);
|
||||
|
||||
if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) {
|
||||
if (selectedMediaCount > 0) {
|
||||
countButton.setVisibility(View.VISIBLE);
|
||||
countButtonText.setText(String.valueOf(state.getSelectionCount()));
|
||||
countButton.setCount(selectedMediaCount);
|
||||
} else {
|
||||
countButton.setVisibility(View.GONE);
|
||||
}
|
||||
@@ -222,7 +272,7 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
captureButton = requireView().findViewById(R.id.camera_capture_button);
|
||||
|
||||
View galleryButton = requireView().findViewById(R.id.camera_gallery_button);
|
||||
View countButton = requireView().findViewById(R.id.camera_count_button);
|
||||
View countButton = requireView().findViewById(R.id.camera_review_button);
|
||||
|
||||
captureButton.setOnClickListener(v -> {
|
||||
captureButton.setEnabled(false);
|
||||
@@ -248,8 +298,6 @@ public class Camera1Fragment extends LoggingFragment implements CameraFragment,
|
||||
|
||||
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
|
||||
countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked());
|
||||
|
||||
viewModel.onCameraControlsInitialized();
|
||||
}
|
||||
|
||||
private void onCaptureClicked() {
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class CameraButtonView extends View {
|
||||
|
||||
@@ -95,7 +96,7 @@ public class CameraButtonView extends View {
|
||||
outlinePaint.setColor(0x26000000);
|
||||
outlinePaint.setAntiAlias(true);
|
||||
outlinePaint.setStyle(Paint.Style.STROKE);
|
||||
outlinePaint.setStrokeWidth(1.5f);
|
||||
outlinePaint.setStrokeWidth(ViewUtil.dpToPx(4));
|
||||
return outlinePaint;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ import java.util.List;
|
||||
public class CameraContactSelectionFragment extends LoggingFragment implements CameraContactAdapter.CameraContactListener {
|
||||
|
||||
private Controller controller;
|
||||
private MediaSendViewModel mediaSendViewModel;
|
||||
private CameraContactSelectionViewModel contactViewModel;
|
||||
private RecyclerView contactList;
|
||||
private CameraContactAdapter contactAdapter;
|
||||
@@ -60,7 +59,6 @@ public class CameraContactSelectionFragment extends LoggingFragment implements C
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
|
||||
this.mediaSendViewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
|
||||
this.contactViewModel = ViewModelProviders.of(requireActivity(), new CameraContactSelectionViewModel.Factory(new CameraContactsRepository(requireContext())))
|
||||
.get(CameraContactSelectionViewModel.class);
|
||||
}
|
||||
@@ -109,12 +107,6 @@ public class CameraContactSelectionFragment extends LoggingFragment implements C
|
||||
initViewModel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
mediaSendViewModel.onContactSelectStarted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareOptionsMenu(@NonNull Menu menu) {
|
||||
requireActivity().getMenuInflater().inflate(R.menu.camera_contacts, menu);
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
||||
@@ -4,8 +4,11 @@ import android.annotation.SuppressLint;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
|
||||
@@ -29,6 +32,10 @@ public interface CameraFragment {
|
||||
}
|
||||
}
|
||||
|
||||
void presentHud(int selectedMediaCount);
|
||||
void fadeOutControls(@NonNull Runnable onEndAction);
|
||||
void fadeInControls();
|
||||
|
||||
interface Controller {
|
||||
void onCameraError();
|
||||
void onImageCaptured(@NonNull byte[] data, int width, int height);
|
||||
@@ -37,5 +44,7 @@ public interface CameraFragment {
|
||||
void onGalleryClicked();
|
||||
int getDisplayRotation();
|
||||
void onCameraCountButtonClicked();
|
||||
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem();
|
||||
@NonNull MediaConstraints getMediaConstraints();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
@@ -17,7 +18,6 @@ import android.view.animation.AnimationUtils;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.RotateAnimation;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -30,7 +30,6 @@ import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.camera.view.SignalCameraView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.util.Executors;
|
||||
@@ -38,9 +37,11 @@ import com.bumptech.glide.util.Executors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView;
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
|
||||
@@ -63,12 +64,11 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
private static final String TAG = Log.tag(CameraXFragment.class);
|
||||
private static final String IS_VIDEO_ENABLED = "is_video_enabled";
|
||||
|
||||
private SignalCameraView camera;
|
||||
private ViewGroup controlsContainer;
|
||||
private Controller controller;
|
||||
private MediaSendViewModel viewModel;
|
||||
private View selfieFlash;
|
||||
private MemoryFileDescriptor videoFileDescriptor;
|
||||
private SignalCameraView camera;
|
||||
private ViewGroup controlsContainer;
|
||||
private Controller controller;
|
||||
private View selfieFlash;
|
||||
private MemoryFileDescriptor videoFileDescriptor;
|
||||
|
||||
public static CameraXFragment newInstanceForAvatarCapture() {
|
||||
CameraXFragment fragment = new CameraXFragment();
|
||||
@@ -92,18 +92,15 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (!(getActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement controller interface.");
|
||||
if (getActivity() instanceof Controller) {
|
||||
this.controller = (Controller) getActivity();
|
||||
} else if (getParentFragment() instanceof Controller) {
|
||||
this.controller = (Controller) getParentFragment();
|
||||
}
|
||||
|
||||
this.controller = (Controller) getActivity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository()))
|
||||
.get(MediaSendViewModel.class);
|
||||
if (controller == null) {
|
||||
throw new IllegalStateException("Parent must implement controller interface.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -114,16 +111,35 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
@SuppressLint("MissingPermission")
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
ViewGroup cameraParent = view.findViewById(R.id.camerax_camera_parent);
|
||||
|
||||
this.camera = view.findViewById(R.id.camerax_camera);
|
||||
this.controlsContainer = view.findViewById(R.id.camerax_controls_container);
|
||||
|
||||
camera.setScaleType(PreviewView.ScaleType.FIT_CENTER);
|
||||
camera.bindToLifecycle(getViewLifecycleOwner());
|
||||
camera.setCameraLensFacing(CameraXUtil.toLensFacing(TextSecurePreferences.getDirectCaptureCameraId(requireContext())));
|
||||
|
||||
onOrientationChanged(getResources().getConfiguration().orientation);
|
||||
|
||||
viewModel.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
|
||||
viewModel.getHudState().observe(getViewLifecycleOwner(), this::presentHud);
|
||||
controller.getMostRecentMediaItem().observe(getViewLifecycleOwner(), this::presentRecentItemThumbnail);
|
||||
|
||||
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
// Let's assume portrait for now, so 9:16
|
||||
float aspectRatio = 9f / 16f;
|
||||
float width = right - left;
|
||||
float height = Math.min((1f / aspectRatio) * width, bottom - top);
|
||||
|
||||
ViewGroup.LayoutParams params = cameraParent.getLayoutParams();
|
||||
|
||||
// If there's a mismatch...
|
||||
if (params.height != (int) height) {
|
||||
params.width = (int) width;
|
||||
params.height = (int) height;
|
||||
|
||||
cameraParent.setLayoutParams(params);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -131,7 +147,6 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
super.onResume();
|
||||
|
||||
camera.bindToLifecycle(getViewLifecycleOwner());
|
||||
viewModel.onCameraStarted();
|
||||
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
|
||||
}
|
||||
@@ -148,6 +163,35 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
onOrientationChanged(newConfig.orientation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fadeOutControls(@NonNull Runnable onEndAction) {
|
||||
controlsContainer.setEnabled(false);
|
||||
controlsContainer.animate()
|
||||
.setDuration(250)
|
||||
.alpha(0f)
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
controlsContainer.setEnabled(true);
|
||||
onEndAction.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fadeInControls() {
|
||||
controlsContainer.setEnabled(false);
|
||||
controlsContainer.animate()
|
||||
.setDuration(250)
|
||||
.alpha(1f)
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
controlsContainer.setEnabled(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void onOrientationChanged(int orientation) {
|
||||
int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait
|
||||
: R.layout.camera_controls_landscape;
|
||||
@@ -176,15 +220,13 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void presentHud(@Nullable MediaSendViewModel.HudState state) {
|
||||
if (state == null) return;
|
||||
@Override
|
||||
public void presentHud(int selectedMediaCount) {
|
||||
MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button);
|
||||
|
||||
View countButton = controlsContainer.findViewById(R.id.camera_count_button);
|
||||
TextView countButtonText = controlsContainer.findViewById(R.id.mediasend_count_button_text);
|
||||
|
||||
if (state.getButtonState() == MediaSendViewModel.ButtonState.COUNT) {
|
||||
if (selectedMediaCount > 0) {
|
||||
countButton.setVisibility(View.VISIBLE);
|
||||
countButtonText.setText(String.valueOf(state.getSelectionCount()));
|
||||
countButton.setCount(selectedMediaCount);
|
||||
} else {
|
||||
countButton.setVisibility(View.GONE);
|
||||
}
|
||||
@@ -195,7 +237,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
View flipButton = requireView().findViewById(R.id.camera_flip_button);
|
||||
CameraButtonView captureButton = requireView().findViewById(R.id.camera_capture_button);
|
||||
View galleryButton = requireView().findViewById(R.id.camera_gallery_button);
|
||||
View countButton = requireView().findViewById(R.id.camera_count_button);
|
||||
View countButton = requireView().findViewById(R.id.camera_review_button);
|
||||
CameraXFlashToggleView flashButton = requireView().findViewById(R.id.camera_flash_button);
|
||||
|
||||
selfieFlash = requireView().findViewById(R.id.camera_selfie_flash);
|
||||
@@ -230,7 +272,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
|
||||
camera.setCaptureMode(SignalCameraView.CaptureMode.MIXED);
|
||||
|
||||
int maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), viewModel.getMediaConstraints());
|
||||
int maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints());
|
||||
Log.d(TAG, "Max duration: " + maxDuration + " sec");
|
||||
|
||||
captureButton.setVideoCaptureListener(new CameraXVideoCaptureHelper(
|
||||
@@ -267,10 +309,8 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
"API: " + Build.VERSION.SDK_INT + ", " +
|
||||
"MFD: " + MemoryFileDescriptor.supported() + ", " +
|
||||
"Camera: " + CameraXUtil.getLowestSupportedHardwareLevel(requireContext()) + ", " +
|
||||
"MaxDuration: " + VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), viewModel.getMediaConstraints()) + " sec");
|
||||
"MaxDuration: " + VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints()) + " sec");
|
||||
}
|
||||
|
||||
viewModel.onCameraControlsInitialized();
|
||||
}
|
||||
|
||||
private boolean isVideoRecordingSupported(@NonNull Context context) {
|
||||
@@ -278,7 +318,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
requireArguments().getBoolean(IS_VIDEO_ENABLED, true) &&
|
||||
MediaConstraints.isVideoTranscodeAvailable() &&
|
||||
CameraXUtil.isMixedModeSupported(context) &&
|
||||
VideoUtil.getMaxVideoRecordDurationInSeconds(context, viewModel.getMediaConstraints()) > 0;
|
||||
VideoUtil.getMaxVideoRecordDurationInSeconds(context, controller.getMediaConstraints()) > 0;
|
||||
}
|
||||
|
||||
private void displayVideoRecordingTooltipIfNecessary(CameraButtonView captureButton) {
|
||||
@@ -389,6 +429,11 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
|
||||
|
||||
@SuppressLint({"MissingPermission"})
|
||||
private void initializeFlipButton(@NonNull View flipButton, @NonNull CameraXFlashToggleView flashButton) {
|
||||
if (getContext() == null) {
|
||||
Log.w(TAG, "initializeFlipButton called either before or after fragment was attached.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT) && camera.hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) {
|
||||
flipButton.setVisibility(View.VISIBLE);
|
||||
flipButton.setOnClickListener(v -> {
|
||||
|
||||
@@ -13,7 +13,7 @@ public final class CompositeMediaTransform implements MediaTransform {
|
||||
|
||||
private final MediaTransform[] transforms;
|
||||
|
||||
CompositeMediaTransform(MediaTransform ...transforms) {
|
||||
public CompositeMediaTransform(MediaTransform ...transforms) {
|
||||
this.transforms = transforms;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@ public final class ImageEditorModelRenderMediaTransform implements MediaTransfor
|
||||
@NonNull private final EditorModel modelToRender;
|
||||
@Nullable private final Point size;
|
||||
|
||||
ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender) {
|
||||
public ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender) {
|
||||
this(modelToRender, null);
|
||||
}
|
||||
|
||||
ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender, @Nullable Point size) {
|
||||
public ImageEditorModelRenderMediaTransform(@NonNull EditorModel modelToRender, @Nullable Point size) {
|
||||
this.modelToRender = modelToRender;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class MediaFolder {
|
||||
this.folderType = folderType;
|
||||
}
|
||||
|
||||
Uri getThumbnailUri() {
|
||||
public Uri getThumbnailUri() {
|
||||
return thumbnailUri;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class MediaFolder {
|
||||
return title;
|
||||
}
|
||||
|
||||
int getItemCount() {
|
||||
public int getItemCount() {
|
||||
return itemCount;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public class MediaFolder {
|
||||
return bucketId;
|
||||
}
|
||||
|
||||
FolderType getFolderType() {
|
||||
public FolderType getFolderType() {
|
||||
return folderType;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
class MediaPickerFolderAdapter extends RecyclerView.Adapter<MediaPickerFolderAdapter.FolderViewHolder> {
|
||||
|
||||
private final GlideRequests glideRequests;
|
||||
private final EventListener eventListener;
|
||||
private final List<MediaFolder> folders;
|
||||
|
||||
MediaPickerFolderAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
|
||||
this.glideRequests = glideRequests;
|
||||
this.eventListener = eventListener;
|
||||
this.folders = new ArrayList<>();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
|
||||
return new FolderViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_folder_item, viewGroup, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull FolderViewHolder folderViewHolder, int i) {
|
||||
folderViewHolder.bind(folders.get(i), glideRequests, eventListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull FolderViewHolder holder) {
|
||||
holder.recycle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return folders.size();
|
||||
}
|
||||
|
||||
void setFolders(@NonNull List<MediaFolder> folders) {
|
||||
this.folders.clear();
|
||||
this.folders.addAll(folders);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
static class FolderViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final ImageView thumbnail;
|
||||
private final ImageView icon;
|
||||
private final TextView title;
|
||||
private final TextView count;
|
||||
|
||||
FolderViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
|
||||
thumbnail = itemView.findViewById(R.id.mediapicker_folder_item_thumbnail);
|
||||
icon = itemView.findViewById(R.id.mediapicker_folder_item_icon);
|
||||
title = itemView.findViewById(R.id.mediapicker_folder_item_title);
|
||||
count = itemView.findViewById(R.id.mediapicker_folder_item_count);
|
||||
}
|
||||
|
||||
void bind(@NonNull MediaFolder folder, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
|
||||
title.setText(folder.getTitle());
|
||||
count.setText(String.valueOf(folder.getItemCount()));
|
||||
icon.setImageResource(folder.getFolderType() == MediaFolder.FolderType.CAMERA ? R.drawable.ic_camera_solid_white_24 : R.drawable.ic_folder_white_48dp);
|
||||
|
||||
glideRequests.load(folder.getThumbnailUri())
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.into(thumbnail);
|
||||
|
||||
itemView.setOnClickListener(v -> eventListener.onFolderClicked(folder));
|
||||
}
|
||||
|
||||
void recycle() {
|
||||
itemView.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
void onFolderClicked(@NonNull MediaFolder mediaFolder);
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Point;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
/**
|
||||
* Allows the user to select a media folder to explore.
|
||||
*/
|
||||
public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener {
|
||||
|
||||
private static final String KEY_TOOLBAR_TITLE = "toolbar_title";
|
||||
private static final String KEY_HIDE_CAMERA = "hide_camera";
|
||||
|
||||
private String toolbarTitle;
|
||||
private boolean showCamera;
|
||||
private MediaSendViewModel viewModel;
|
||||
private Controller controller;
|
||||
private GridLayoutManager layoutManager;
|
||||
|
||||
public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient) {
|
||||
return newInstance(context, recipient, false);
|
||||
}
|
||||
|
||||
public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient, boolean hideCamera) {
|
||||
String toolbarTitle;
|
||||
|
||||
if (recipient != null) {
|
||||
String name = recipient.getDisplayName(context);
|
||||
toolbarTitle = context.getString(R.string.MediaPickerActivity_send_to, name);
|
||||
} else {
|
||||
toolbarTitle = "";
|
||||
}
|
||||
|
||||
return newInstance(toolbarTitle, hideCamera);
|
||||
}
|
||||
|
||||
public static @NonNull MediaPickerFolderFragment newInstance(@NonNull String toolbarTitle, boolean hideCamera) {
|
||||
Bundle args = new Bundle();
|
||||
args.putString(KEY_TOOLBAR_TITLE, toolbarTitle);
|
||||
args.putBoolean(KEY_HIDE_CAMERA, hideCamera);
|
||||
|
||||
MediaPickerFolderFragment fragment = new MediaPickerFolderFragment();
|
||||
fragment.setArguments(args);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
|
||||
toolbarTitle = getArguments().getString(KEY_TOOLBAR_TITLE);
|
||||
showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA);
|
||||
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (!(getActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement controller class.");
|
||||
}
|
||||
|
||||
controller = (Controller) getActivity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.mediapicker_folder_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
RecyclerView list = view.findViewById(R.id.mediapicker_folder_list);
|
||||
MediaPickerFolderAdapter adapter = new MediaPickerFolderAdapter(GlideApp.with(this), this);
|
||||
|
||||
layoutManager = new GridLayoutManager(requireContext(), 2);
|
||||
onScreenWidthChanged(getScreenWidth());
|
||||
|
||||
list.setLayoutManager(layoutManager);
|
||||
list.setAdapter(adapter);
|
||||
|
||||
viewModel.getFolders(requireContext()).observe(getViewLifecycleOwner(), adapter::setFolders);
|
||||
|
||||
initToolbar(view.findViewById(R.id.mediapicker_toolbar));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
viewModel.onFolderPickerStarted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareOptionsMenu(@NonNull Menu menu) {
|
||||
if (showCamera) {
|
||||
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(@NonNull Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
onScreenWidthChanged(getScreenWidth());
|
||||
}
|
||||
|
||||
private void initToolbar(Toolbar toolbar) {
|
||||
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
|
||||
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(toolbarTitle);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
|
||||
}
|
||||
|
||||
private void onScreenWidthChanged(int newWidth) {
|
||||
if (layoutManager != null) {
|
||||
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_folder_width));
|
||||
}
|
||||
}
|
||||
|
||||
private int getScreenWidth() {
|
||||
Point size = new Point();
|
||||
requireActivity().getWindowManager().getDefaultDisplay().getSize(size);
|
||||
return size.x;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFolderClicked(@NonNull MediaFolder folder) {
|
||||
controller.onFolderSelected(folder);
|
||||
}
|
||||
|
||||
public interface Controller {
|
||||
void onFolderSelected(@NonNull MediaFolder folder);
|
||||
void onCameraSelected();
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class MediaPickerItemAdapter extends RecyclerView.Adapter<MediaPickerItemAdapter.ItemViewHolder> {
|
||||
|
||||
private final GlideRequests glideRequests;
|
||||
private final EventListener eventListener;
|
||||
private final List<Media> media;
|
||||
private final List<Media> selected;
|
||||
private final int maxSelection;
|
||||
private final StableIdGenerator<Media> stableIdGenerator;
|
||||
|
||||
private boolean forcedMultiSelect;
|
||||
|
||||
public MediaPickerItemAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, int maxSelection) {
|
||||
this.glideRequests = glideRequests;
|
||||
this.eventListener = eventListener;
|
||||
this.media = new ArrayList<>();
|
||||
this.maxSelection = maxSelection;
|
||||
this.stableIdGenerator = new StableIdGenerator<>();
|
||||
this.selected = new LinkedList<>();
|
||||
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
|
||||
return new ItemViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_media_item, viewGroup, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ItemViewHolder holder, int i) {
|
||||
holder.bind(media.get(i), forcedMultiSelect, selected, maxSelection, glideRequests, eventListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull ItemViewHolder holder) {
|
||||
holder.recycle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return media.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return stableIdGenerator.getId(media.get(position));
|
||||
}
|
||||
|
||||
void setMedia(@NonNull List<Media> media) {
|
||||
this.media.clear();
|
||||
this.media.addAll(media);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
void setSelected(@NonNull Collection<Media> selected) {
|
||||
this.selected.clear();
|
||||
this.selected.addAll(selected);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
List<Media> getSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
void setForcedMultiSelect(boolean forcedMultiSelect) {
|
||||
this.forcedMultiSelect = forcedMultiSelect;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
static class ItemViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final ImageView thumbnail;
|
||||
private final View playOverlay;
|
||||
private final View selectOn;
|
||||
private final View selectOff;
|
||||
private final View selectOverlay;
|
||||
private final TextView selectOrder;
|
||||
|
||||
ItemViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
thumbnail = itemView.findViewById(R.id.mediapicker_image_item_thumbnail);
|
||||
playOverlay = itemView.findViewById(R.id.mediapicker_play_overlay);
|
||||
selectOn = itemView.findViewById(R.id.mediapicker_select_on);
|
||||
selectOff = itemView.findViewById(R.id.mediapicker_select_off);
|
||||
selectOverlay = itemView.findViewById(R.id.mediapicker_select_overlay);
|
||||
selectOrder = itemView.findViewById(R.id.mediapicker_select_order);
|
||||
}
|
||||
|
||||
void bind(@NonNull Media media, boolean multiSelect, List<Media> selected, int maxSelection, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
|
||||
glideRequests.load(media.getUri())
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.into(thumbnail);
|
||||
|
||||
playOverlay.setVisibility(MediaUtil.isVideoType(media.getMimeType()) ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (selected.isEmpty() && !multiSelect) {
|
||||
itemView.setOnClickListener(v -> eventListener.onMediaChosen(media));
|
||||
selectOn.setVisibility(View.GONE);
|
||||
selectOff.setVisibility(View.GONE);
|
||||
selectOverlay.setVisibility(View.GONE);
|
||||
|
||||
if (maxSelection > 1) {
|
||||
itemView.setOnLongClickListener(v -> {
|
||||
selected.add(media);
|
||||
eventListener.onMediaSelectionStarted();
|
||||
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
} else if (selected.contains(media)) {
|
||||
selectOff.setVisibility(View.VISIBLE);
|
||||
selectOn.setVisibility(View.VISIBLE);
|
||||
selectOverlay.setVisibility(View.VISIBLE);
|
||||
selectOrder.setText(String.valueOf(selected.indexOf(media) + 1));
|
||||
itemView.setOnLongClickListener(null);
|
||||
itemView.setOnClickListener(v -> {
|
||||
selected.remove(media);
|
||||
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
|
||||
});
|
||||
} else {
|
||||
selectOff.setVisibility(View.VISIBLE);
|
||||
selectOn.setVisibility(View.GONE);
|
||||
selectOverlay.setVisibility(View.GONE);
|
||||
itemView.setOnLongClickListener(null);
|
||||
itemView.setOnClickListener(v -> {
|
||||
if (selected.size() < maxSelection) {
|
||||
selected.add(media);
|
||||
eventListener.onMediaSelectionChanged(new ArrayList<>(selected));
|
||||
} else {
|
||||
eventListener.onMediaSelectionOverflow(maxSelection);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void recycle() {
|
||||
itemView.setOnClickListener(null);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
void onMediaChosen(@NonNull Media media);
|
||||
void onMediaSelectionStarted();
|
||||
void onMediaSelectionChanged(@NonNull List<Media> media);
|
||||
void onMediaSelectionOverflow(int maxSelection);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Point;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Allows the user to select a set of media items from a specified folder.
|
||||
*/
|
||||
public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener {
|
||||
|
||||
private static final String KEY_BUCKET_ID = "bucket_id";
|
||||
private static final String KEY_FOLDER_TITLE = "folder_title";
|
||||
private static final String KEY_MAX_SELECTION = "max_selection";
|
||||
private static final String KEY_FORCE_MULTI_SELECT = "force_multi_select";
|
||||
private static final String KEY_HIDE_CAMERA = "hide_camera";
|
||||
|
||||
private String bucketId;
|
||||
private String folderTitle;
|
||||
private int maxSelection;
|
||||
private boolean showCamera;
|
||||
private MediaSendViewModel viewModel;
|
||||
private MediaPickerItemAdapter adapter;
|
||||
private Controller controller;
|
||||
private GridLayoutManager layoutManager;
|
||||
|
||||
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) {
|
||||
return newInstance(bucketId, folderTitle, maxSelection, true);
|
||||
}
|
||||
|
||||
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect) {
|
||||
return newInstance(bucketId, folderTitle, maxSelection, forceMultiSelect, false);
|
||||
}
|
||||
|
||||
public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect, boolean hideCamera) {
|
||||
Bundle args = new Bundle();
|
||||
args.putString(KEY_BUCKET_ID, bucketId);
|
||||
args.putString(KEY_FOLDER_TITLE, folderTitle);
|
||||
args.putInt(KEY_MAX_SELECTION, maxSelection);
|
||||
args.putBoolean(KEY_FORCE_MULTI_SELECT, forceMultiSelect);
|
||||
args.putBoolean(KEY_HIDE_CAMERA, hideCamera);
|
||||
|
||||
MediaPickerItemFragment fragment = new MediaPickerItemFragment();
|
||||
fragment.setArguments(args);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
|
||||
bucketId = getArguments().getString(KEY_BUCKET_ID);
|
||||
folderTitle = getArguments().getString(KEY_FOLDER_TITLE);
|
||||
maxSelection = getArguments().getInt(KEY_MAX_SELECTION);
|
||||
showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA);
|
||||
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (!(getActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement controller class.");
|
||||
}
|
||||
|
||||
controller = (Controller) getActivity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.mediapicker_item_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list);
|
||||
|
||||
adapter = new MediaPickerItemAdapter(GlideApp.with(this), this, maxSelection);
|
||||
layoutManager = new GridLayoutManager(requireContext(), 4);
|
||||
|
||||
imageList.setLayoutManager(layoutManager);
|
||||
imageList.setAdapter(adapter);
|
||||
|
||||
initToolbar(view.findViewById(R.id.mediapicker_toolbar));
|
||||
onScreenWidthChanged(getScreenWidth());
|
||||
|
||||
viewModel.getSelectedMedia().observe(getViewLifecycleOwner(), adapter::setSelected);
|
||||
viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
viewModel.onItemPickerStarted();
|
||||
if (requireArguments().getBoolean(KEY_FORCE_MULTI_SELECT)) {
|
||||
adapter.setForcedMultiSelect(true);
|
||||
viewModel.onMultiSelectStarted();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareOptionsMenu(@NonNull Menu menu) {
|
||||
if (showCamera) {
|
||||
requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(@NonNull Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
onScreenWidthChanged(getScreenWidth());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaChosen(@NonNull Media media) {
|
||||
controller.onMediaSelected(media);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaSelectionStarted() {
|
||||
viewModel.onMultiSelectStarted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaSelectionChanged(@NonNull List<Media> selected) {
|
||||
adapter.notifyDataSetChanged();
|
||||
viewModel.onSelectedMediaChanged(requireContext(), selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaSelectionOverflow(int maxSelection) {
|
||||
Toast.makeText(requireContext(), getResources().getQuantityString(R.plurals.MediaSendActivity_cant_share_more_than_n_items, maxSelection, maxSelection), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void initToolbar(Toolbar toolbar) {
|
||||
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
|
||||
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(folderTitle);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
|
||||
}
|
||||
|
||||
private void onScreenWidthChanged(int newWidth) {
|
||||
if (layoutManager != null) {
|
||||
layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width));
|
||||
}
|
||||
}
|
||||
|
||||
private int getScreenWidth() {
|
||||
Point size = new Point();
|
||||
requireActivity().getWindowManager().getDefaultDisplay().getSize(size);
|
||||
return size.x;
|
||||
}
|
||||
|
||||
public interface Controller {
|
||||
void onMediaSelected(@NonNull Media media);
|
||||
void onCameraSelected();
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public class MediaRepository {
|
||||
/**
|
||||
* Retrieves a list of folders that contain media.
|
||||
*/
|
||||
void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> callback) {
|
||||
public void getFolders(@NonNull Context context, @NonNull Callback<List<MediaFolder>> callback) {
|
||||
if (!StorageUtil.canReadFromMediaStore()) {
|
||||
Log.w(TAG, "No storage permissions!", new Throwable());
|
||||
callback.onComplete(Collections.emptyList());
|
||||
@@ -76,7 +76,7 @@ public class MediaRepository {
|
||||
* Given an existing list of {@link Media}, this will ensure that the media is populate with as
|
||||
* much data as we have, like width/height.
|
||||
*/
|
||||
void getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media, @NonNull Callback<List<Media>> callback) {
|
||||
public void getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media, @NonNull Callback<List<Media>> callback) {
|
||||
if (Stream.of(media).allMatch(this::isPopulated)) {
|
||||
callback.onComplete(media);
|
||||
return;
|
||||
@@ -107,7 +107,7 @@ public class MediaRepository {
|
||||
@NonNull Map<Media, MediaTransform> modelsToTransform,
|
||||
@NonNull Callback<LinkedHashMap<Media, Media>> callback)
|
||||
{
|
||||
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(transformMedia(context, currentMedia, modelsToTransform)));
|
||||
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(transformMediaSync(context, currentMedia, modelsToTransform)));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@@ -256,7 +256,7 @@ public class MediaRepository {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private List<Media> getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media) {
|
||||
public List<Media> getPopulatedMedia(@NonNull Context context, @NonNull List<Media> media) {
|
||||
return media.stream()
|
||||
.map(m -> {
|
||||
try {
|
||||
@@ -276,9 +276,9 @@ public class MediaRepository {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private static LinkedHashMap<Media, Media> transformMedia(@NonNull Context context,
|
||||
@NonNull List<Media> currentMedia,
|
||||
@NonNull Map<Media, MediaTransform> modelsToTransform)
|
||||
public static LinkedHashMap<Media, Media> transformMediaSync(@NonNull Context context,
|
||||
@NonNull List<Media> currentMedia,
|
||||
@NonNull Map<Media, MediaTransform> modelsToTransform)
|
||||
{
|
||||
LinkedHashMap<Media, Media> updatedMedia = new LinkedHashMap<>(currentMedia.size());
|
||||
|
||||
@@ -367,7 +367,7 @@ public class MediaRepository {
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static @NonNull Media fixMimeType(@NonNull Context context, @NonNull Media media) {
|
||||
public static @NonNull Media fixMimeType(@NonNull Context context, @NonNull Media media) {
|
||||
if (MediaUtil.isOctetStream(media.getMimeType())) {
|
||||
Log.w(TAG, "Media has mimetype octet stream");
|
||||
String newMimeType = MediaUtil.getMimeType(context, media.getUri());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
@@ -21,6 +22,9 @@ import java.util.List;
|
||||
* A class that lets us nicely format data that we'll send back to {@link ConversationActivity}.
|
||||
*/
|
||||
public class MediaSendActivityResult implements Parcelable {
|
||||
|
||||
public static final String EXTRA_RESULT = "result";
|
||||
|
||||
private final RecipientId recipientId;
|
||||
private final Collection<PreUploadResult> uploadResults;
|
||||
private final Collection<Media> nonUploadedMedia;
|
||||
@@ -29,23 +33,32 @@ public class MediaSendActivityResult implements Parcelable {
|
||||
private final boolean viewOnce;
|
||||
private final Collection<Mention> mentions;
|
||||
|
||||
static @NonNull MediaSendActivityResult forPreUpload(@NonNull RecipientId recipientId,
|
||||
@NonNull Collection<PreUploadResult> uploadResults,
|
||||
@NonNull String body,
|
||||
@NonNull TransportOption transport,
|
||||
boolean viewOnce,
|
||||
@NonNull List<Mention> mentions)
|
||||
public static @NonNull MediaSendActivityResult fromData(@NonNull Intent data) {
|
||||
MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivityResult.EXTRA_RESULT);
|
||||
if (result == null) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static @NonNull MediaSendActivityResult forPreUpload(@NonNull RecipientId recipientId,
|
||||
@NonNull Collection<PreUploadResult> uploadResults,
|
||||
@NonNull String body,
|
||||
@NonNull TransportOption transport,
|
||||
boolean viewOnce,
|
||||
@NonNull List<Mention> mentions)
|
||||
{
|
||||
Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!");
|
||||
return new MediaSendActivityResult(recipientId, uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions);
|
||||
}
|
||||
|
||||
static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull RecipientId recipientId,
|
||||
@NonNull List<Media> nonUploadedMedia,
|
||||
@NonNull String body,
|
||||
@NonNull TransportOption transport,
|
||||
boolean viewOnce,
|
||||
@NonNull List<Mention> mentions)
|
||||
public static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull RecipientId recipientId,
|
||||
@NonNull List<Media> nonUploadedMedia,
|
||||
@NonNull String body,
|
||||
@NonNull TransportOption transport,
|
||||
boolean viewOnce,
|
||||
@NonNull List<Mention> mentions)
|
||||
{
|
||||
Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!");
|
||||
return new MediaSendActivityResult(recipientId, Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions);
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ControllableViewPager;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Allows the user to edit and caption a set of media items before choosing to send them.
|
||||
*/
|
||||
public class MediaSendFragment extends Fragment {
|
||||
|
||||
private ViewGroup playbackControlsContainer;
|
||||
private ControllableViewPager fragmentPager;
|
||||
private MediaSendFragmentPagerAdapter fragmentPagerAdapter;
|
||||
|
||||
private MediaSendViewModel viewModel;
|
||||
|
||||
public static MediaSendFragment newInstance() {
|
||||
Bundle args = new Bundle();
|
||||
|
||||
MediaSendFragment fragment = new MediaSendFragment();
|
||||
fragment.setArguments(args);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.mediasend_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
initViewModel();
|
||||
fragmentPager = view.findViewById(R.id.mediasend_pager);
|
||||
playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container);
|
||||
|
||||
fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager(), viewModel.isSms() ? MediaConstraints.getMmsMediaConstraints(-1) : MediaConstraints.getPushMediaConstraints(null));
|
||||
fragmentPager.setAdapter(fragmentPagerAdapter);
|
||||
|
||||
FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener();
|
||||
fragmentPager.addOnPageChangeListener(pageChangeListener);
|
||||
fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
|
||||
fragmentPagerAdapter.restoreState(viewModel.getDrawState());
|
||||
viewModel.onImageEditorStarted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHiddenChanged(boolean hidden) {
|
||||
super.onHiddenChanged(hidden);
|
||||
if (!hidden) {
|
||||
viewModel.onImageEditorStarted();
|
||||
} else {
|
||||
fragmentPagerAdapter.notifyHidden();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
fragmentPagerAdapter.notifyHidden();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
fragmentPagerAdapter.saveAllState();
|
||||
viewModel.saveDrawState(fragmentPagerAdapter.getSavedState());
|
||||
}
|
||||
|
||||
public void onTouchEventsNeeded(boolean needed) {
|
||||
if (fragmentPager != null) {
|
||||
fragmentPager.setEnabled(!needed);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Media> getAllMedia() {
|
||||
return fragmentPagerAdapter.getAllMedia();
|
||||
}
|
||||
|
||||
public @NonNull Map<Uri, Object> getSavedState() {
|
||||
return fragmentPagerAdapter.getSavedState();
|
||||
}
|
||||
|
||||
public int getCurrentImagePosition() {
|
||||
return fragmentPager.getCurrentItem();
|
||||
}
|
||||
|
||||
private void initViewModel() {
|
||||
viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
|
||||
|
||||
viewModel.getSelectedMedia().observe(getViewLifecycleOwner(), media -> {
|
||||
if (Util.isEmpty(media)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fragmentPagerAdapter.setMedia(media);
|
||||
});
|
||||
|
||||
viewModel.getPosition().observe(getViewLifecycleOwner(), position -> {
|
||||
if (position == null || position < 0) return;
|
||||
|
||||
fragmentPager.setCurrentItem(position, true);
|
||||
|
||||
View playbackControls = fragmentPagerAdapter.getPlaybackControls(position);
|
||||
|
||||
if (playbackControls != null) {
|
||||
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
playbackControls.setLayoutParams(params);
|
||||
playbackControlsContainer.removeAllViews();
|
||||
playbackControlsContainer.addView(playbackControls);
|
||||
} else {
|
||||
playbackControlsContainer.removeAllViews();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void pausePlayback() {
|
||||
fragmentPagerAdapter.pausePlayback();
|
||||
}
|
||||
|
||||
private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
|
||||
@Override
|
||||
public void onPageSelected(int position) {
|
||||
viewModel.onPageChanged(position);
|
||||
fragmentPagerAdapter.notifyPageChanged(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentStatePagerAdapter;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter {
|
||||
|
||||
private final List<Media> media;
|
||||
private final Map<Integer, MediaSendPageFragment> fragments;
|
||||
private final Map<Uri, Object> savedState;
|
||||
private final MediaConstraints mediaConstraints;
|
||||
|
||||
MediaSendFragmentPagerAdapter(@NonNull FragmentManager fm, @NonNull MediaConstraints mediaConstraints) {
|
||||
super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
|
||||
this.mediaConstraints = mediaConstraints;
|
||||
this.media = new ArrayList<>();
|
||||
this.fragments = new HashMap<>();
|
||||
this.savedState = new HashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Fragment getItem(int i) {
|
||||
Media mediaItem = media.get(i);
|
||||
|
||||
if (MediaUtil.isGif(mediaItem.getMimeType())) {
|
||||
return MediaSendGifFragment.newInstance(mediaItem.getUri());
|
||||
} else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
|
||||
return ImageEditorFragment.newInstance(mediaItem.getUri());
|
||||
} else if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
|
||||
return MediaSendVideoFragment.newInstance(mediaItem.getUri(),
|
||||
mediaConstraints.getCompressedVideoMaxSize(ApplicationDependencies.getApplication()),
|
||||
mediaConstraints.getVideoMaxSize(ApplicationDependencies.getApplication()),
|
||||
mediaItem.isVideoGif());
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.getMimeType() + "'");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemPosition(@NonNull Object object) {
|
||||
return POSITION_NONE;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Object instantiateItem(@NonNull ViewGroup container, int position) {
|
||||
MediaSendPageFragment fragment = (MediaSendPageFragment) super.instantiateItem(container, position);
|
||||
fragments.put(position, fragment);
|
||||
|
||||
Object state = savedState.get(fragment.getUri());
|
||||
if (state != null) {
|
||||
fragment.restoreState(state);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
|
||||
MediaSendPageFragment fragment = (MediaSendPageFragment) object;
|
||||
|
||||
Object state = fragment.saveState();
|
||||
if (state != null) {
|
||||
savedState.put(fragment.getUri(), state);
|
||||
}
|
||||
|
||||
super.destroyItem(container, position, object);
|
||||
fragments.remove(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return media.size();
|
||||
}
|
||||
|
||||
List<Media> getAllMedia() {
|
||||
return media;
|
||||
}
|
||||
|
||||
void setMedia(@NonNull List<Media> media) {
|
||||
this.media.clear();
|
||||
this.media.addAll(media);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
Map<Uri, Object> getSavedState() {
|
||||
for (MediaSendPageFragment fragment : fragments.values()) {
|
||||
Object state = fragment.saveState();
|
||||
if (state != null) {
|
||||
savedState.put(fragment.getUri(), state);
|
||||
}
|
||||
}
|
||||
return new HashMap<>(savedState);
|
||||
}
|
||||
|
||||
void saveAllState() {
|
||||
for (MediaSendPageFragment fragment : fragments.values()) {
|
||||
Object state = fragment.saveState();
|
||||
if (state != null) {
|
||||
savedState.put(fragment.getUri(), state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void restoreState(@NonNull Map<Uri, Object> state) {
|
||||
savedState.clear();
|
||||
savedState.putAll(state);
|
||||
}
|
||||
|
||||
@Nullable View getPlaybackControls(int position) {
|
||||
return fragments.containsKey(position) ? fragments.get(position).getPlaybackControls() : null;
|
||||
}
|
||||
|
||||
void pausePlayback() {
|
||||
for (MediaSendPageFragment fragment : fragments.values()) {
|
||||
if (fragment instanceof MediaSendVideoFragment) {
|
||||
((MediaSendVideoFragment)fragment).pausePlayback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void notifyHidden() {
|
||||
Stream.of(fragments.values()).forEach(MediaSendPageFragment::notifyHidden);
|
||||
}
|
||||
|
||||
void notifyPageChanged(int currentPage) {
|
||||
notifyHiddenIfExists(currentPage - 1);
|
||||
notifyHiddenIfExists(currentPage + 1);
|
||||
}
|
||||
|
||||
private void notifyHiddenIfExists(int position) {
|
||||
MediaSendPageFragment fragment = fragments.get(position);
|
||||
|
||||
if (fragment != null) {
|
||||
fragment.notifyHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,809 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
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.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
|
||||
import org.thoughtcrime.securesms.util.DiffHelper;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageUtil;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Manages the observable datasets available in {@link MediaSendActivity}.
|
||||
*/
|
||||
class MediaSendViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(MediaSendViewModel.class);
|
||||
|
||||
private final Application application;
|
||||
private final MediaRepository repository;
|
||||
private final MediaUploadRepository uploadRepository;
|
||||
private final MutableLiveData<List<Media>> selectedMedia;
|
||||
private final MutableLiveData<List<Media>> bucketMedia;
|
||||
private final MutableLiveData<Optional<Media>> mostRecentMedia;
|
||||
private final MutableLiveData<Integer> position;
|
||||
private final MutableLiveData<String> bucketId;
|
||||
private final MutableLiveData<List<MediaFolder>> folders;
|
||||
private final MutableLiveData<HudState> hudState;
|
||||
private final SingleLiveEvent<Error> error;
|
||||
private final SingleLiveEvent<Event> event;
|
||||
private final MutableLiveData<SentMediaQuality> sentMediaQuality;
|
||||
private final LiveData<Boolean> showMediaQualityToggle;
|
||||
private final Map<Uri, Object> savedDrawState;
|
||||
|
||||
private TransportOption transport;
|
||||
private MediaConstraints mediaConstraints;
|
||||
private CharSequence body;
|
||||
private boolean sentMedia;
|
||||
private int maxSelection;
|
||||
private Page page;
|
||||
private boolean isSms;
|
||||
private boolean meteredConnection;
|
||||
private Optional<Media> lastCameraCapture;
|
||||
private boolean preUploadEnabled;
|
||||
|
||||
private boolean hudVisible;
|
||||
private boolean composeVisible;
|
||||
private boolean captionVisible;
|
||||
private ButtonState buttonState;
|
||||
private RailState railState;
|
||||
private ViewOnceState viewOnceState;
|
||||
|
||||
private @Nullable Recipient recipient;
|
||||
|
||||
private MediaSendViewModel(@NonNull Application application,
|
||||
@NonNull MediaRepository repository,
|
||||
@NonNull MediaUploadRepository uploadRepository)
|
||||
{
|
||||
this.application = application;
|
||||
this.repository = repository;
|
||||
this.uploadRepository = uploadRepository;
|
||||
this.selectedMedia = new MutableLiveData<>();
|
||||
this.bucketMedia = new MutableLiveData<>();
|
||||
this.mostRecentMedia = new MutableLiveData<>();
|
||||
this.position = new MutableLiveData<>();
|
||||
this.bucketId = new MutableLiveData<>();
|
||||
this.folders = new MutableLiveData<>();
|
||||
this.hudState = new MutableLiveData<>();
|
||||
this.error = new SingleLiveEvent<>();
|
||||
this.event = new SingleLiveEvent<>();
|
||||
this.sentMediaQuality = new MutableLiveData<>(SentMediaQuality.STANDARD);
|
||||
this.savedDrawState = new HashMap<>();
|
||||
this.lastCameraCapture = Optional.absent();
|
||||
this.body = "";
|
||||
this.buttonState = ButtonState.GONE;
|
||||
this.railState = RailState.GONE;
|
||||
this.viewOnceState = ViewOnceState.GONE;
|
||||
this.page = Page.UNKNOWN;
|
||||
this.preUploadEnabled = true;
|
||||
this.showMediaQualityToggle = LiveDataUtil.mapAsync(this.selectedMedia, s -> s.stream().anyMatch(m -> MediaUtil.isImageAndNotGif(m.getMimeType())));
|
||||
|
||||
position.setValue(-1);
|
||||
}
|
||||
|
||||
void setTransport(@NonNull TransportOption transport) {
|
||||
this.transport = transport;
|
||||
|
||||
if (transport.isSms()) {
|
||||
isSms = true;
|
||||
maxSelection = MediaSendConstants.MAX_SMS;
|
||||
mediaConstraints = MediaConstraints.getMmsMediaConstraints(transport.getSimSubscriptionId().or(-1));
|
||||
} else {
|
||||
isSms = false;
|
||||
maxSelection = MediaSendConstants.MAX_PUSH;
|
||||
mediaConstraints = MediaConstraints.getPushMediaConstraints();
|
||||
}
|
||||
|
||||
preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
|
||||
}
|
||||
|
||||
void setRecipient(@Nullable Recipient recipient) {
|
||||
this.recipient = recipient;
|
||||
this.preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
|
||||
}
|
||||
|
||||
void onSelectedMediaChanged(@NonNull Context context, @NonNull List<Media> newMedia) {
|
||||
List<Media> originalMedia = getSelectedMediaOrDefault();
|
||||
|
||||
repository.getPopulatedMedia(context, newMedia, populatedMedia -> {
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
List<Media> filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints);
|
||||
|
||||
if (filteredMedia.size() != newMedia.size()) {
|
||||
if (filteredMedia.isEmpty() && newMedia.size() == 1 && page == Page.UNKNOWN) {
|
||||
if (MediaUtil.isImageOrVideoType(newMedia.get(0).getMimeType())) {
|
||||
error.setValue(Error.ONLY_ITEM_TOO_LARGE);
|
||||
} else {
|
||||
error.setValue(Error.ONLY_ITEM_IS_INVALID_TYPE);
|
||||
}
|
||||
} else {
|
||||
if (newMedia.stream().allMatch(m -> MediaUtil.isImageOrVideoType(m.getMimeType()))) {
|
||||
error.setValue(Error.ITEM_TOO_LARGE);
|
||||
} else {
|
||||
error.setValue(Error.ITEM_TOO_LARGE_OR_INVALID_TYPE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredMedia.size() > maxSelection) {
|
||||
filteredMedia = filteredMedia.subList(0, maxSelection);
|
||||
error.setValue(Error.TOO_MANY_ITEMS);
|
||||
}
|
||||
|
||||
if (filteredMedia.size() > 0) {
|
||||
String computedId = Stream.of(filteredMedia)
|
||||
.skip(1)
|
||||
.reduce(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID), (id, m) -> {
|
||||
if (Util.equals(id, m.getBucketId().or(Media.ALL_MEDIA_BUCKET_ID))) {
|
||||
return id;
|
||||
} else {
|
||||
return Media.ALL_MEDIA_BUCKET_ID;
|
||||
}
|
||||
});
|
||||
bucketId.setValue(computedId);
|
||||
} else {
|
||||
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
|
||||
}
|
||||
|
||||
if (page == Page.EDITOR && filteredMedia.isEmpty()) {
|
||||
error.postValue(Error.NO_ITEMS);
|
||||
} else if (filteredMedia.isEmpty()) {
|
||||
hudVisible = false;
|
||||
selectedMedia.setValue(filteredMedia);
|
||||
hudState.setValue(buildHudState());
|
||||
} else {
|
||||
hudVisible = true;
|
||||
selectedMedia.setValue(filteredMedia);
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
updateAttachmentUploads(originalMedia, getSelectedMediaOrDefault());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void onSingleMediaSelected(@NonNull Context context, @NonNull Media media) {
|
||||
repository.getPopulatedMedia(context, Collections.singletonList(media), populatedMedia -> {
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
List<Media> filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints);
|
||||
|
||||
if (filteredMedia.isEmpty()) {
|
||||
error.setValue(Error.ITEM_TOO_LARGE);
|
||||
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
|
||||
} else {
|
||||
bucketId.setValue(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID));
|
||||
}
|
||||
|
||||
selectedMedia.setValue(filteredMedia);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void onMultiSelectStarted() {
|
||||
hudVisible = true;
|
||||
composeVisible = false;
|
||||
captionVisible = false;
|
||||
buttonState = ButtonState.COUNT;
|
||||
railState = RailState.VIEWABLE;
|
||||
viewOnceState = ViewOnceState.GONE;
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onImageEditorStarted() {
|
||||
page = Page.EDITOR;
|
||||
hudVisible = true;
|
||||
captionVisible = getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent());
|
||||
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
|
||||
|
||||
updateViewOnceState();
|
||||
showViewOnceTooltipIfNecessary(viewOnceState);
|
||||
|
||||
railState = !isSms && viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
|
||||
composeVisible = viewOnceState != ViewOnceState.ENABLED;
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onCameraStarted() {
|
||||
// TODO: Don't need this?
|
||||
Page previous = page;
|
||||
|
||||
page = Page.CAMERA;
|
||||
hudVisible = false;
|
||||
viewOnceState = ViewOnceState.GONE;
|
||||
buttonState = ButtonState.COUNT;
|
||||
|
||||
List<Media> selected = getSelectedMediaOrDefault();
|
||||
|
||||
if (previous == Page.EDITOR && lastCameraCapture.isPresent() && selected.contains(lastCameraCapture.get()) && selected.size() == 1) {
|
||||
selected.remove(lastCameraCapture.get());
|
||||
selectedMedia.setValue(selected);
|
||||
BlobProvider.getInstance().delete(application, lastCameraCapture.get().getUri());
|
||||
cancelUpload(lastCameraCapture.get());
|
||||
}
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onItemPickerStarted() {
|
||||
page = Page.ITEM_PICKER;
|
||||
hudVisible = true;
|
||||
composeVisible = false;
|
||||
captionVisible = false;
|
||||
buttonState = ButtonState.COUNT;
|
||||
viewOnceState = ViewOnceState.GONE;
|
||||
railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE;
|
||||
|
||||
lastCameraCapture = Optional.absent();
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onFolderPickerStarted() {
|
||||
page = Page.FOLDER_PICKER;
|
||||
hudVisible = true;
|
||||
composeVisible = false;
|
||||
captionVisible = false;
|
||||
buttonState = ButtonState.COUNT;
|
||||
viewOnceState = ViewOnceState.GONE;
|
||||
railState = getSelectedMediaOrDefault().isEmpty() ? RailState.GONE : RailState.VIEWABLE;
|
||||
|
||||
lastCameraCapture = Optional.absent();
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onContactSelectStarted() {
|
||||
hudVisible = false;
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onRevealButtonToggled() {
|
||||
hudVisible = true;
|
||||
viewOnceState = viewOnceState == ViewOnceState.ENABLED ? ViewOnceState.DISABLED : ViewOnceState.ENABLED;
|
||||
composeVisible = viewOnceState != ViewOnceState.ENABLED;
|
||||
railState = viewOnceState == ViewOnceState.ENABLED || isSms ? RailState.GONE : RailState.INTERACTIVE;
|
||||
captionVisible = false;
|
||||
|
||||
List<Media> uncaptioned = Stream.of(getSelectedMediaOrDefault())
|
||||
.map(m -> new Media(m.getUri(), m.getMimeType(), m.getDate(), m.getWidth(), m.getHeight(), m.getSize(), m.getDuration(), m.isBorderless(), m.isVideoGif(), m.getBucketId(), Optional.absent(), Optional.absent()))
|
||||
.toList();
|
||||
|
||||
selectedMedia.setValue(uncaptioned);
|
||||
position.setValue(position.getValue() != null ? position.getValue() : 0);
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onKeyboardHidden(boolean isSms) {
|
||||
if (page != Page.EDITOR) return;
|
||||
|
||||
composeVisible = (viewOnceState != ViewOnceState.ENABLED);
|
||||
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
|
||||
|
||||
if (isSms) {
|
||||
railState = RailState.GONE;
|
||||
captionVisible = false;
|
||||
} else {
|
||||
railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
|
||||
|
||||
if (getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent())) {
|
||||
captionVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onKeyboardShown(boolean isComposeFocused, boolean isCaptionFocused, boolean isSms) {
|
||||
if (page != Page.EDITOR) return;
|
||||
|
||||
if (isSms) {
|
||||
railState = RailState.GONE;
|
||||
composeVisible = (viewOnceState == ViewOnceState.GONE);
|
||||
captionVisible = false;
|
||||
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
|
||||
} else {
|
||||
if (isCaptionFocused) {
|
||||
railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
|
||||
composeVisible = false;
|
||||
captionVisible = true;
|
||||
buttonState = ButtonState.GONE;
|
||||
} else if (isComposeFocused) {
|
||||
railState = viewOnceState != ViewOnceState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
|
||||
composeVisible = (viewOnceState != ViewOnceState.ENABLED);
|
||||
captionVisible = false;
|
||||
buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
|
||||
}
|
||||
}
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onBodyChanged(@NonNull CharSequence body) {
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
void onFolderSelected(@NonNull String bucketId) {
|
||||
this.bucketId.setValue(bucketId);
|
||||
bucketMedia.setValue(Collections.emptyList());
|
||||
}
|
||||
|
||||
void onPageChanged(int position) {
|
||||
if (position < 0 || position >= getSelectedMediaOrDefault().size()) {
|
||||
Log.w(TAG, "Tried to move to an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position);
|
||||
return;
|
||||
}
|
||||
|
||||
this.position.setValue(position);
|
||||
}
|
||||
|
||||
void onMediaItemRemoved(@NonNull Context context, int position) {
|
||||
if (position < 0 || position >= getSelectedMediaOrDefault().size()) {
|
||||
Log.w(TAG, "Tried to remove an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position);
|
||||
return;
|
||||
}
|
||||
|
||||
Media removed = getSelectedMediaOrDefault().remove(position);
|
||||
|
||||
if (removed != null && BlobProvider.isAuthority(removed.getUri())) {
|
||||
BlobProvider.getInstance().delete(context, removed.getUri());
|
||||
}
|
||||
|
||||
cancelUpload(removed);
|
||||
|
||||
if (page == Page.EDITOR && getSelectedMediaOrDefault().isEmpty()) {
|
||||
error.setValue(Error.NO_ITEMS);
|
||||
} else {
|
||||
selectedMedia.setValue(selectedMedia.getValue());
|
||||
}
|
||||
|
||||
if (getSelectedMediaOrDefault().size() > 0) {
|
||||
this.position.setValue(Math.min(position, getSelectedMediaOrDefault().size() - 1));
|
||||
}
|
||||
|
||||
if (getSelectedMediaOrDefault().size() == 1) {
|
||||
viewOnceState = viewOnceSupported() ? ViewOnceState.DISABLED : ViewOnceState.GONE;
|
||||
}
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
void onVideoBeginEdit(@NonNull Uri uri) {
|
||||
cancelUpload(new Media(uri, "", 0, 0, 0, 0, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent()));
|
||||
}
|
||||
|
||||
void onMediaCaptured(@NonNull Media media) {
|
||||
lastCameraCapture = Optional.of(media);
|
||||
|
||||
List<Media> selected = selectedMedia.getValue();
|
||||
|
||||
if (selected == null) {
|
||||
selected = new LinkedList<>();
|
||||
}
|
||||
|
||||
if (selected.size() >= maxSelection) {
|
||||
error.setValue(Error.TOO_MANY_ITEMS);
|
||||
return;
|
||||
}
|
||||
|
||||
selected.add(media);
|
||||
selectedMedia.setValue(selected);
|
||||
position.setValue(selected.size() - 1);
|
||||
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
|
||||
|
||||
startUpload(media);
|
||||
}
|
||||
|
||||
void onCaptionChanged(@NonNull String newCaption) {
|
||||
if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) {
|
||||
selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption);
|
||||
}
|
||||
}
|
||||
|
||||
void onCameraControlsInitialized() {
|
||||
repository.getMostRecentItem(application, mostRecentMedia::postValue);
|
||||
}
|
||||
|
||||
void onMeteredConnectivityStatusChanged(boolean metered) {
|
||||
Log.i(TAG, "Metered connectivity status set to: " + metered);
|
||||
|
||||
meteredConnection = metered;
|
||||
preUploadEnabled = shouldPreUpload(application, metered, isSms, recipient);
|
||||
}
|
||||
|
||||
void saveDrawState(@NonNull Map<Uri, Object> state) {
|
||||
savedDrawState.clear();
|
||||
savedDrawState.putAll(state);
|
||||
}
|
||||
|
||||
public void setSentMediaQuality(@NonNull SentMediaQuality newQuality) {
|
||||
if (newQuality == sentMediaQuality.getValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
sentMediaQuality.setValue(newQuality);
|
||||
preUploadEnabled = false;
|
||||
uploadRepository.cancelAllUploads();
|
||||
}
|
||||
|
||||
@NonNull LiveData<MediaSendActivityResult> onSendClicked(Map<Media, MediaTransform> modelsToTransform, @NonNull List<Recipient> recipients, @NonNull List<Mention> mentions) {
|
||||
if (isSms && recipients.size() > 0) {
|
||||
throw new IllegalStateException("Provided recipients to send to, but this is SMS!");
|
||||
}
|
||||
|
||||
MutableLiveData<MediaSendActivityResult> result = new MutableLiveData<>();
|
||||
String trimmedBody = isViewOnce() ? "" : body.toString().trim();
|
||||
List<Media> initialMedia = getSelectedMediaOrDefault();
|
||||
List<Mention> trimmedMentions = isViewOnce() ? Collections.emptyList() : mentions;
|
||||
|
||||
Preconditions.checkState(initialMedia.size() > 0, "No media to send!");
|
||||
|
||||
MediaRepository.transformMedia(application, initialMedia, modelsToTransform, (oldToNew) -> {
|
||||
List<Media> updatedMedia = new ArrayList<>(oldToNew.values());
|
||||
|
||||
for (Media media : updatedMedia){
|
||||
Log.w(TAG, media.getUri().toString() + " : " + media.getTransformProperties().transform(t->"" + t.isVideoTrim()).or("null"));
|
||||
}
|
||||
|
||||
if (isSms || MessageSender.isLocalSelfSend(application, recipient, isSms)) {
|
||||
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.");
|
||||
result.postValue(MediaSendActivityResult.forTraditionalSend(recipient.getId(), updatedMedia, trimmedBody, transport, isViewOnce(), trimmedMentions));
|
||||
return;
|
||||
}
|
||||
|
||||
MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(application, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize);
|
||||
String splitBody = splitMessage.getBody();
|
||||
|
||||
if (splitMessage.getTextSlide().isPresent()) {
|
||||
Slide slide = splitMessage.getTextSlide().get();
|
||||
uploadRepository.startUpload(new Media(Objects.requireNonNull(slide.getUri()), slide.getContentType(), System.currentTimeMillis(), 0, 0, slide.getFileSize(), 0, slide.isBorderless(), slide.isVideoGif(), Optional.absent(), Optional.absent(), Optional.absent()), recipient);
|
||||
}
|
||||
|
||||
uploadRepository.applyMediaUpdates(oldToNew, recipient);
|
||||
uploadRepository.updateCaptions(updatedMedia);
|
||||
uploadRepository.updateDisplayOrder(updatedMedia);
|
||||
uploadRepository.getPreUploadResults(uploadResults -> {
|
||||
if (recipients.size() > 0) {
|
||||
sendMessages(recipients, splitBody, uploadResults, trimmedMentions);
|
||||
uploadRepository.deleteAbandonedAttachments();
|
||||
result.postValue(null);
|
||||
} else {
|
||||
result.postValue(MediaSendActivityResult.forPreUpload(recipient.getId(), uploadResults, splitBody, transport, isViewOnce(), trimmedMentions));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sentMedia = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@NonNull Map<Uri, Object> getDrawState() {
|
||||
return savedDrawState;
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<Media>> getSelectedMedia() {
|
||||
return selectedMedia;
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<Media>> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) {
|
||||
repository.getMediaInBucket(context, bucketId, bucketMedia::postValue);
|
||||
return bucketMedia;
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<MediaFolder>> getFolders(@NonNull Context context) {
|
||||
repository.getFolders(context, folders::postValue);
|
||||
return folders;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem() {
|
||||
return mostRecentMedia;
|
||||
}
|
||||
|
||||
@NonNull CharSequence getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Integer> getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
@NonNull LiveData<String> getBucketId() {
|
||||
return bucketId;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Error> getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Event> getEvents() {
|
||||
return event;
|
||||
}
|
||||
|
||||
@NonNull LiveData<HudState> getHudState() {
|
||||
return hudState;
|
||||
}
|
||||
|
||||
int getMaxSelection() {
|
||||
return maxSelection;
|
||||
}
|
||||
|
||||
boolean isViewOnce() {
|
||||
return viewOnceState == ViewOnceState.ENABLED;
|
||||
}
|
||||
|
||||
@NonNull LiveData<SentMediaQuality> getSentMediaQuality() {
|
||||
return sentMediaQuality;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> getShowMediaQualityToggle() {
|
||||
return showMediaQualityToggle;
|
||||
}
|
||||
|
||||
@NonNull MediaConstraints getMediaConstraints() {
|
||||
return mediaConstraints;
|
||||
}
|
||||
|
||||
private void updateViewOnceState() {
|
||||
if (viewOnceState == ViewOnceState.GONE && viewOnceSupported()) {
|
||||
showViewOnceTooltipIfNecessary(viewOnceState);
|
||||
viewOnceState = ViewOnceState.DISABLED;
|
||||
} else if (!viewOnceSupported()) {
|
||||
viewOnceState = ViewOnceState.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull List<Media> getSelectedMediaOrDefault() {
|
||||
return selectedMedia.getValue() == null ? Collections.emptyList()
|
||||
: selectedMedia.getValue();
|
||||
}
|
||||
|
||||
private @NonNull List<Media> getFilteredMedia(@NonNull Context context, @NonNull List<Media> media, @NonNull MediaConstraints mediaConstraints) {
|
||||
return Stream.of(media).filter(m -> MediaUtil.isGif(m.getMimeType()) ||
|
||||
MediaUtil.isImageType(m.getMimeType()) ||
|
||||
MediaUtil.isVideoType(m.getMimeType()))
|
||||
.filter(m -> {
|
||||
return (MediaUtil.isImageType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) ||
|
||||
(MediaUtil.isGif(m.getMimeType()) && m.getSize() < mediaConstraints.getGifMaxSize(context)) ||
|
||||
(MediaUtil.isVideoType(m.getMimeType()) && m.getSize() < mediaConstraints.getUncompressedVideoMaxSize(context));
|
||||
}).toList();
|
||||
|
||||
}
|
||||
|
||||
private HudState buildHudState() {
|
||||
List<Media> selectedMedia = getSelectedMediaOrDefault();
|
||||
int selectionCount = selectedMedia.size();
|
||||
ButtonState updatedButtonState = buttonState == ButtonState.COUNT && selectionCount == 0 ? ButtonState.GONE : buttonState;
|
||||
boolean updatedCaptionVisible = captionVisible && (selectedMedia.size() > 1 || (selectedMedia.size() > 0 && selectedMedia.get(0).getCaption().isPresent()));
|
||||
|
||||
updateViewOnceState();
|
||||
|
||||
return new HudState(hudVisible, composeVisible, updatedCaptionVisible, selectionCount, updatedButtonState, railState, viewOnceState);
|
||||
}
|
||||
|
||||
private void clearPersistedMedia() {
|
||||
Stream.of(getSelectedMediaOrDefault())
|
||||
.map(Media::getUri)
|
||||
.filter(BlobProvider::isAuthority)
|
||||
.forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri));
|
||||
}
|
||||
|
||||
private boolean viewOnceSupported() {
|
||||
return !isSms && (recipient == null || !recipient.isSelf()) && mediaSupportsRevealableMessage(getSelectedMediaOrDefault());
|
||||
}
|
||||
|
||||
private boolean mediaSupportsRevealableMessage(@NonNull List<Media> media) {
|
||||
if (media.size() != 1) return false;
|
||||
return MediaUtil.isImageOrVideoType(media.get(0).getMimeType());
|
||||
}
|
||||
|
||||
private void showViewOnceTooltipIfNecessary(@NonNull ViewOnceState viewOnceState) {
|
||||
if (viewOnceState == ViewOnceState.DISABLED && !TextSecurePreferences.hasSeenViewOnceTooltip(application)) {
|
||||
event.postValue(Event.VIEW_ONCE_TOOLTIP);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateAttachmentUploads(@NonNull List<Media> oldMedia, @NonNull List<Media> newMedia) {
|
||||
if (!preUploadEnabled) return;
|
||||
|
||||
DiffHelper.Result<Media> result = DiffHelper.calculate(oldMedia, newMedia);
|
||||
|
||||
uploadRepository.cancelUpload(result.getRemoved());
|
||||
uploadRepository.startUpload(result.getInserted(), recipient);
|
||||
}
|
||||
|
||||
private void cancelUpload(@NonNull Media media) {
|
||||
uploadRepository.cancelUpload(media);
|
||||
}
|
||||
|
||||
private void startUpload(@NonNull Media media) {
|
||||
if (!preUploadEnabled) return;
|
||||
uploadRepository.startUpload(media, recipient);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void sendMessages(@NonNull List<Recipient> recipients, @NonNull String body, @NonNull Collection<PreUploadResult> preUploadResults, @NonNull List<Mention> mentions) {
|
||||
List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size());
|
||||
|
||||
for (Recipient recipient : recipients) {
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
|
||||
body,
|
||||
Collections.emptyList(),
|
||||
System.currentTimeMillis(),
|
||||
-1,
|
||||
TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds()),
|
||||
isViewOnce(),
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
mentions,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList());
|
||||
|
||||
messages.add(new 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.
|
||||
ThreadUtil.sleep(5);
|
||||
}
|
||||
|
||||
MessageSender.sendMediaBroadcast(application, messages, preUploadResults);
|
||||
}
|
||||
|
||||
private static boolean shouldPreUpload(@NonNull Context context, boolean metered, boolean isSms, @Nullable Recipient recipient) {
|
||||
return !metered && !isSms && !MessageSender.isLocalSelfSend(context, recipient, isSms);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
if (!sentMedia) {
|
||||
clearPersistedMedia();
|
||||
uploadRepository.cancelAllUploads();
|
||||
uploadRepository.deleteAbandonedAttachments();
|
||||
}
|
||||
}
|
||||
|
||||
boolean isSms() {
|
||||
return transport.isSms();
|
||||
}
|
||||
|
||||
enum Error {
|
||||
ITEM_TOO_LARGE, TOO_MANY_ITEMS, NO_ITEMS, ONLY_ITEM_TOO_LARGE, ONLY_ITEM_IS_INVALID_TYPE, ITEM_TOO_LARGE_OR_INVALID_TYPE
|
||||
}
|
||||
|
||||
enum Event {
|
||||
VIEW_ONCE_TOOLTIP
|
||||
}
|
||||
|
||||
enum Page {
|
||||
CAMERA, ITEM_PICKER, FOLDER_PICKER, EDITOR, CONTACT_SELECT, UNKNOWN
|
||||
}
|
||||
|
||||
enum ButtonState {
|
||||
COUNT, SEND, CONTINUE, GONE
|
||||
}
|
||||
|
||||
enum RailState {
|
||||
INTERACTIVE, VIEWABLE, GONE
|
||||
}
|
||||
|
||||
enum ViewOnceState {
|
||||
ENABLED, DISABLED, GONE
|
||||
}
|
||||
|
||||
static class HudState {
|
||||
|
||||
private final boolean hudVisible;
|
||||
private final boolean composeVisible;
|
||||
private final boolean captionVisible;
|
||||
private final int selectionCount;
|
||||
private final ButtonState buttonState;
|
||||
private final RailState railState;
|
||||
private final ViewOnceState viewOnceState;
|
||||
|
||||
HudState(boolean hudVisible,
|
||||
boolean composeVisible,
|
||||
boolean captionVisible,
|
||||
int selectionCount,
|
||||
@NonNull ButtonState buttonState,
|
||||
@NonNull RailState railState,
|
||||
@NonNull ViewOnceState viewOnceState)
|
||||
{
|
||||
this.hudVisible = hudVisible;
|
||||
this.composeVisible = composeVisible;
|
||||
this.captionVisible = captionVisible;
|
||||
this.selectionCount = selectionCount;
|
||||
this.buttonState = buttonState;
|
||||
this.railState = railState;
|
||||
this.viewOnceState = viewOnceState;
|
||||
}
|
||||
|
||||
public boolean isHudVisible() {
|
||||
return hudVisible;
|
||||
}
|
||||
|
||||
public boolean isComposeVisible() {
|
||||
return hudVisible && composeVisible;
|
||||
}
|
||||
|
||||
public boolean isCaptionVisible() {
|
||||
return hudVisible && captionVisible;
|
||||
}
|
||||
|
||||
public int getSelectionCount() {
|
||||
return selectionCount;
|
||||
}
|
||||
|
||||
public @NonNull ButtonState getButtonState() {
|
||||
return buttonState;
|
||||
}
|
||||
|
||||
public @NonNull RailState getRailState() {
|
||||
return hudVisible ? railState : RailState.GONE;
|
||||
}
|
||||
|
||||
public @NonNull ViewOnceState getViewOnceState() {
|
||||
return hudVisible ? viewOnceState : ViewOnceState.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
|
||||
private final Application application;
|
||||
private final MediaRepository repository;
|
||||
|
||||
Factory(@NonNull Application application, @NonNull MediaRepository repository) {
|
||||
this.application = application;
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
return modelClass.cast(new MediaSendViewModel(application, repository, new MediaUploadRepository(application)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ import java.util.concurrent.Executor;
|
||||
* This also means that unlike most repositories, the class itself is stateful. Keep that in mind
|
||||
* when using it.
|
||||
*/
|
||||
class MediaUploadRepository {
|
||||
public class MediaUploadRepository {
|
||||
|
||||
private static final String TAG = Log.tag(MediaUploadRepository.class);
|
||||
|
||||
@@ -53,17 +53,17 @@ class MediaUploadRepository {
|
||||
private final LinkedHashMap<Media, PreUploadResult> uploadResults;
|
||||
private final Executor executor;
|
||||
|
||||
MediaUploadRepository(@NonNull Context context) {
|
||||
public MediaUploadRepository(@NonNull Context context) {
|
||||
this.context = context;
|
||||
this.uploadResults = new LinkedHashMap<>();
|
||||
this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-MediaUpload");
|
||||
}
|
||||
|
||||
void startUpload(@NonNull Media media, @Nullable Recipient recipient) {
|
||||
public void startUpload(@NonNull Media media, @Nullable Recipient recipient) {
|
||||
executor.execute(() -> uploadMediaInternal(media, recipient));
|
||||
}
|
||||
|
||||
void startUpload(@NonNull Collection<Media> mediaItems, @Nullable Recipient recipient) {
|
||||
public void startUpload(@NonNull Collection<Media> mediaItems, @Nullable Recipient recipient) {
|
||||
executor.execute(() -> {
|
||||
for (Media media : mediaItems) {
|
||||
cancelUploadInternal(media);
|
||||
@@ -76,7 +76,7 @@ class MediaUploadRepository {
|
||||
* Given a map of old->new, cancel medias that were changed and upload their replacements. Will
|
||||
* also upload any media in the map that wasn't yet uploaded.
|
||||
*/
|
||||
void applyMediaUpdates(@NonNull Map<Media, Media> oldToNew, @Nullable Recipient recipient) {
|
||||
public void applyMediaUpdates(@NonNull Map<Media, Media> oldToNew, @Nullable Recipient recipient) {
|
||||
executor.execute(() -> {
|
||||
for (Map.Entry<Media, Media> entry : oldToNew.entrySet()) {
|
||||
Media oldMedia = entry.getKey();
|
||||
@@ -101,11 +101,11 @@ class MediaUploadRepository {
|
||||
return !newProperties.isVideoEdited() && oldProperties.getSentMediaQuality() == newProperties.getSentMediaQuality();
|
||||
}
|
||||
|
||||
void cancelUpload(@NonNull Media media) {
|
||||
public void cancelUpload(@NonNull Media media) {
|
||||
executor.execute(() -> cancelUploadInternal(media));
|
||||
}
|
||||
|
||||
void cancelUpload(@NonNull Collection<Media> mediaItems) {
|
||||
public void cancelUpload(@NonNull Collection<Media> mediaItems) {
|
||||
executor.execute(() -> {
|
||||
for (Media media : mediaItems) {
|
||||
cancelUploadInternal(media);
|
||||
@@ -113,7 +113,7 @@ class MediaUploadRepository {
|
||||
});
|
||||
}
|
||||
|
||||
void cancelAllUploads() {
|
||||
public void cancelAllUploads() {
|
||||
executor.execute(() -> {
|
||||
for (Media media : new HashSet<>(uploadResults.keySet())) {
|
||||
cancelUploadInternal(media);
|
||||
@@ -121,19 +121,19 @@ class MediaUploadRepository {
|
||||
});
|
||||
}
|
||||
|
||||
void getPreUploadResults(@NonNull Callback<Collection<PreUploadResult>> callback) {
|
||||
public void getPreUploadResults(@NonNull Callback<Collection<PreUploadResult>> callback) {
|
||||
executor.execute(() -> callback.onResult(uploadResults.values()));
|
||||
}
|
||||
|
||||
void updateCaptions(@NonNull List<Media> updatedMedia) {
|
||||
public void updateCaptions(@NonNull List<Media> updatedMedia) {
|
||||
executor.execute(() -> updateCaptionsInternal(updatedMedia));
|
||||
}
|
||||
|
||||
void updateDisplayOrder(@NonNull List<Media> mediaInOrder) {
|
||||
public void updateDisplayOrder(@NonNull List<Media> mediaInOrder) {
|
||||
executor.execute(() -> updateDisplayOrderInternal(mediaInOrder));
|
||||
}
|
||||
|
||||
void deleteAbandonedAttachments() {
|
||||
public void deleteAbandonedAttachments() {
|
||||
executor.execute(() -> {
|
||||
int deleted = DatabaseFactory.getAttachmentDatabase(context).deleteAbandonedPreuploadedAttachments();
|
||||
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
|
||||
@@ -216,7 +216,7 @@ class MediaUploadRepository {
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback<E> {
|
||||
public interface Callback<E> {
|
||||
void onResult(@NonNull E result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.ConnectivityManager;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.net.ConnectivityManagerCompat;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
/**
|
||||
* Lifecycle-bound observer for whether or not the active network connection is metered.
|
||||
*/
|
||||
class MeteredConnectivityObserver extends BroadcastReceiver implements DefaultLifecycleObserver {
|
||||
|
||||
private final Context context;
|
||||
private final ConnectivityManager connectivityManager;
|
||||
private final MutableLiveData<Boolean> metered;
|
||||
|
||||
@MainThread
|
||||
MeteredConnectivityObserver(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
|
||||
this.context = context;
|
||||
this.connectivityManager = ServiceUtil.getConnectivityManager(context);
|
||||
this.metered = new MutableLiveData<>();
|
||||
|
||||
this.metered.setValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
|
||||
lifecycleOwner.getLifecycle().addObserver(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@NonNull LifecycleOwner owner) {
|
||||
context.registerReceiver(this, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(@NonNull LifecycleOwner owner) {
|
||||
context.unregisterReceiver(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
metered.postValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return An observable value that is false when the network is unmetered, and true if the
|
||||
* network is either metered or unavailable.
|
||||
*/
|
||||
@NonNull LiveData<Boolean> isMetered() {
|
||||
return metered;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public final class SentMediaQualityTransform implements MediaTransform {
|
||||
|
||||
private final SentMediaQuality sentMediaQuality;
|
||||
|
||||
SentMediaQualityTransform(@NonNull SentMediaQuality sentMediaQuality) {
|
||||
public SentMediaQualityTransform(@NonNull SentMediaQuality sentMediaQuality) {
|
||||
this.sentMediaQuality = sentMediaQuality;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@ import org.thoughtcrime.securesms.video.VideoPlayer;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.EventListener,
|
||||
MediaSendPageFragment {
|
||||
public class VideoEditorFragment extends Fragment implements VideoEditorHud.EventListener,
|
||||
MediaSendPageFragment {
|
||||
|
||||
private static final String TAG = Log.tag(MediaSendVideoFragment.class);
|
||||
private static final String TAG = Log.tag(VideoEditorFragment.class);
|
||||
|
||||
private static final String KEY_URI = "uri";
|
||||
private static final String KEY_MAX_OUTPUT = "max_output_size";
|
||||
@@ -46,14 +46,14 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
|
||||
@Nullable private VideoEditorHud hud;
|
||||
private Runnable updatePosition;
|
||||
|
||||
public static MediaSendVideoFragment newInstance(@NonNull Uri uri, long maxCompressedVideoSize, long maxAttachmentSize, boolean isVideoGif) {
|
||||
public static VideoEditorFragment newInstance(@NonNull Uri uri, long maxCompressedVideoSize, long maxAttachmentSize, boolean isVideoGif) {
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(KEY_URI, uri);
|
||||
args.putLong(KEY_MAX_OUTPUT, maxCompressedVideoSize);
|
||||
args.putLong(KEY_MAX_SEND, maxAttachmentSize);
|
||||
args.putBoolean(KEY_IS_VIDEO_GIF, isVideoGif);
|
||||
|
||||
MediaSendVideoFragment fragment = new MediaSendVideoFragment();
|
||||
VideoEditorFragment fragment = new VideoEditorFragment();
|
||||
fragment.setArguments(args);
|
||||
fragment.setUri(uri);
|
||||
return fragment;
|
||||
@@ -62,10 +62,13 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (!(getActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement Controller interface.");
|
||||
if (getActivity() instanceof Controller) {
|
||||
controller = (Controller) getActivity();
|
||||
} else if (getParentFragment() instanceof Controller) {
|
||||
controller = (Controller) getParentFragment();
|
||||
} else {
|
||||
throw new IllegalStateException("Parent must implement Controller interface.");
|
||||
}
|
||||
controller = (Controller) getActivity();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -116,6 +119,7 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
|
||||
|
||||
@Override
|
||||
public void onPlaying() {
|
||||
controller.onPlayerReady();
|
||||
hud.fadePlayButton();
|
||||
}
|
||||
|
||||
@@ -123,6 +127,11 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
|
||||
public void onStopped() {
|
||||
hud.showPlayButton();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError() {
|
||||
controller.onPlayerError();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -273,6 +282,10 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
|
||||
if (!wasEdited && durationEdited) {
|
||||
controller.onVideoBeginEdit(uri);
|
||||
}
|
||||
|
||||
if (editingComplete) {
|
||||
controller.onVideoEndEdit(uri);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -292,17 +305,47 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E
|
||||
});
|
||||
}
|
||||
|
||||
static class Data {
|
||||
public static class Data {
|
||||
boolean durationEdited;
|
||||
long totalDurationUs;
|
||||
long startTimeUs;
|
||||
long endTimeUs;
|
||||
|
||||
public boolean isDurationEdited() {
|
||||
return durationEdited;
|
||||
}
|
||||
|
||||
public @NonNull Bundle getBundle() {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putByte("EDITED", (byte) (durationEdited ? 1 : 0));
|
||||
bundle.putLong("TOTAL", totalDurationUs);
|
||||
bundle.putLong("START", startTimeUs);
|
||||
bundle.putLong("END", endTimeUs);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public static @NonNull Data fromBundle(@NonNull Bundle bundle) {
|
||||
Data data = new Data();
|
||||
data.durationEdited = bundle.getByte("EDITED") == (byte) 1;
|
||||
data.totalDurationUs = bundle.getLong("TOTAL");
|
||||
data.startTimeUs = bundle.getLong("START");
|
||||
data.endTimeUs = bundle.getLong("END");
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Controller {
|
||||
|
||||
void onPlayerReady();
|
||||
|
||||
void onPlayerError();
|
||||
|
||||
void onTouchEventsNeeded(boolean needed);
|
||||
|
||||
void onVideoBeginEdit(@NonNull Uri uri);
|
||||
|
||||
void onVideoEndEdit(@NonNull Uri uri);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public final class VideoTrimTransform implements MediaTransform {
|
||||
|
||||
private final MediaSendVideoFragment.Data data;
|
||||
private final VideoEditorFragment.Data data;
|
||||
|
||||
VideoTrimTransform(@NonNull MediaSendVideoFragment.Data data) {
|
||||
public VideoTrimTransform(@NonNull VideoEditorFragment.Data data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.view.KeyEvent
|
||||
|
||||
sealed class HudCommand {
|
||||
object StartDraw : HudCommand()
|
||||
object StartCropAndRotate : HudCommand()
|
||||
object SaveMedia : HudCommand()
|
||||
|
||||
object ResumeEntryTransition : HudCommand()
|
||||
|
||||
object OpenEmojiSearch : HudCommand()
|
||||
object CloseEmojiSearch : HudCommand()
|
||||
data class EmojiInsert(val emoji: String?) : HudCommand()
|
||||
data class EmojiKeyEvent(val keyEvent: KeyEvent?) : HudCommand()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.animation.Animator
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
object MediaAnimations {
|
||||
private const val FADE_ANIMATION_DURATION = 150L
|
||||
|
||||
fun fadeIn(view: View) {
|
||||
if (view.visible) {
|
||||
return
|
||||
}
|
||||
|
||||
view.visible = true
|
||||
view.animate()
|
||||
.setDuration(FADE_ANIMATION_DURATION)
|
||||
.alpha(1f)
|
||||
}
|
||||
|
||||
fun fadeOut(view: View) {
|
||||
if (!view.visible) {
|
||||
return
|
||||
}
|
||||
|
||||
view.animate()
|
||||
.setDuration(FADE_ANIMATION_DURATION)
|
||||
.setListener(object : AnimationCompleteListener() {
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
view.visible = false
|
||||
}
|
||||
})
|
||||
.alpha(0f)
|
||||
}
|
||||
|
||||
fun fade(view: View, fadeIn: Boolean) {
|
||||
if (fadeIn) {
|
||||
fadeIn(view)
|
||||
} else {
|
||||
fadeOut(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
|
||||
object MediaBuilder {
|
||||
fun buildMedia(
|
||||
uri: Uri,
|
||||
mimeType: String = "",
|
||||
date: Long = 0L,
|
||||
width: Int = 0,
|
||||
height: Int = 0,
|
||||
size: Long = 0L,
|
||||
duration: Long = 0L,
|
||||
borderless: Boolean = false,
|
||||
videoGif: Boolean = false,
|
||||
bucketId: Optional<String> = Optional.absent(),
|
||||
caption: Optional<String> = Optional.absent(),
|
||||
transformProperties: Optional<AttachmentDatabase.TransformProperties> = Optional.absent()
|
||||
) = Media(uri, mimeType, date, width, height, size, duration, borderless, videoGif, bucketId, caption, transformProperties)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class MediaCountIndicatorButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.v2_media_count_indicator_button, this)
|
||||
}
|
||||
|
||||
private val countView: TextView = findViewById(R.id.media_count_indicator_text)
|
||||
|
||||
fun setCount(count: Int) {
|
||||
countView.text = "$count"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.TransportOption
|
||||
import org.thoughtcrime.securesms.TransportOptions
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
|
||||
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class MediaSelectionActivity :
|
||||
PassphraseRequiredActivity(),
|
||||
MediaReviewFragment.Callback,
|
||||
EmojiKeyboardPageFragment.Callback,
|
||||
EmojiEventListener,
|
||||
EmojiSearchFragment.Callback {
|
||||
|
||||
lateinit var viewModel: MediaSelectionViewModel
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
super.attachBaseContext(newBase)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
setContentView(R.layout.fragment_container)
|
||||
|
||||
val transportOption: TransportOption = requireNotNull(intent.getParcelableExtra(TRANSPORT_OPTION))
|
||||
val initialMedia: List<Media> = intent.getParcelableArrayListExtra(MEDIA) ?: listOf()
|
||||
val destination: MediaSelectionDestination = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
|
||||
val message: CharSequence? = intent.getCharSequenceExtra(MESSAGE)
|
||||
val isReply: Boolean = intent.getBooleanExtra(IS_REPLY, false)
|
||||
|
||||
val factory = MediaSelectionViewModel.Factory(destination, transportOption, initialMedia, message, isReply, MediaSelectionRepository(this))
|
||||
viewModel = ViewModelProviders.of(this, factory)[MediaSelectionViewModel::class.java]
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val navHostFragment = NavHostFragment.create(R.navigation.media)
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, navHostFragment, NAV_HOST_TAG)
|
||||
.commitNowAllowingStateLoss()
|
||||
|
||||
navigateToStartDestination()
|
||||
} else {
|
||||
viewModel.onRestoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(OnBackPressed())
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewModel.onSaveState(outState)
|
||||
}
|
||||
|
||||
override fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) {
|
||||
setResult(
|
||||
RESULT_OK,
|
||||
Intent().apply {
|
||||
putExtra(MediaSendActivityResult.EXTRA_RESULT, mediaSendActivityResult)
|
||||
}
|
||||
)
|
||||
|
||||
finish()
|
||||
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom)
|
||||
}
|
||||
|
||||
override fun onSentWithoutResult() {
|
||||
val intent = Intent()
|
||||
setResult(RESULT_OK, intent)
|
||||
|
||||
finish()
|
||||
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom)
|
||||
}
|
||||
|
||||
override fun onSendError(error: Throwable) {
|
||||
setResult(RESULT_CANCELED)
|
||||
|
||||
// TODO [alex] - Toast
|
||||
Log.w(TAG, "Failed to send message.", error)
|
||||
|
||||
finish()
|
||||
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom)
|
||||
}
|
||||
|
||||
override fun onPopFromReview() {
|
||||
if (isCameraFirst()) {
|
||||
viewModel.removeCameraFirstCapture()
|
||||
}
|
||||
|
||||
if (!navigateToStartDestination()) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToStartDestination(navHostFragment: NavHostFragment? = null): Boolean {
|
||||
val hostFragment: NavHostFragment = navHostFragment ?: supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment
|
||||
|
||||
val startDestination: Int = intent.getIntExtra(START_ACTION, -1)
|
||||
return if (startDestination > 0) {
|
||||
hostFragment.navController.navigate(
|
||||
startDestination,
|
||||
Bundle().apply {
|
||||
putBoolean("first", true)
|
||||
}
|
||||
)
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCameraFirst(): Boolean = intent.getIntExtra(START_ACTION, -1) == R.id.action_directly_to_mediaCaptureFragment
|
||||
|
||||
override fun openEmojiSearch() {
|
||||
viewModel.sendCommand(HudCommand.OpenEmojiSearch)
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(emoji: String?) {
|
||||
viewModel.sendCommand(HudCommand.EmojiInsert(emoji))
|
||||
}
|
||||
|
||||
override fun onKeyEvent(keyEvent: KeyEvent?) {
|
||||
viewModel.sendCommand(HudCommand.EmojiKeyEvent(keyEvent))
|
||||
}
|
||||
|
||||
override fun closeEmojiSearch() {
|
||||
viewModel.sendCommand(HudCommand.CloseEmojiSearch)
|
||||
}
|
||||
|
||||
private inner class OnBackPressed : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val navController = Navigation.findNavController(this@MediaSelectionActivity, R.id.fragment_container)
|
||||
if (!navController.popBackStack()) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MediaSelectionActivity::class.java)
|
||||
|
||||
private const val NAV_HOST_TAG = "NAV_HOST"
|
||||
|
||||
private const val START_ACTION = "start.action"
|
||||
private const val TRANSPORT_OPTION = "transport.option"
|
||||
private const val MEDIA = "media"
|
||||
private const val MESSAGE = "message"
|
||||
private const val DESTINATION = "destination"
|
||||
private const val IS_REPLY = "is_reply"
|
||||
|
||||
@JvmStatic
|
||||
fun camera(context: Context): Intent {
|
||||
return buildIntent(
|
||||
context = context,
|
||||
startAction = R.id.action_directly_to_mediaCaptureFragment
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun camera(
|
||||
context: Context,
|
||||
transportOption: TransportOption,
|
||||
recipientId: RecipientId,
|
||||
isReply: Boolean
|
||||
): Intent {
|
||||
return buildIntent(
|
||||
context = context,
|
||||
startAction = R.id.action_directly_to_mediaCaptureFragment,
|
||||
transportOption = transportOption,
|
||||
destination = MediaSelectionDestination.SingleRecipient(recipientId),
|
||||
isReply = isReply
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun gallery(
|
||||
context: Context,
|
||||
transportOption: TransportOption,
|
||||
media: List<Media>,
|
||||
recipientId: RecipientId,
|
||||
message: CharSequence?,
|
||||
isReply: Boolean
|
||||
): Intent {
|
||||
return buildIntent(
|
||||
context = context,
|
||||
startAction = R.id.action_directly_to_mediaGalleryFragment,
|
||||
transportOption = transportOption,
|
||||
media = media,
|
||||
destination = MediaSelectionDestination.SingleRecipient(recipientId),
|
||||
message = message,
|
||||
isReply = isReply
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun editor(
|
||||
context: Context,
|
||||
transportOption: TransportOption,
|
||||
media: List<Media>,
|
||||
recipientId: RecipientId,
|
||||
message: CharSequence?
|
||||
): Intent {
|
||||
return buildIntent(
|
||||
context = context,
|
||||
transportOption = transportOption,
|
||||
media = media,
|
||||
destination = MediaSelectionDestination.SingleRecipient(recipientId),
|
||||
message = message
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun share(
|
||||
context: Context,
|
||||
transportOption: TransportOption,
|
||||
media: List<Media>,
|
||||
recipientIds: List<RecipientId>,
|
||||
message: CharSequence?
|
||||
): Intent {
|
||||
return buildIntent(
|
||||
context = context,
|
||||
transportOption = transportOption,
|
||||
media = media,
|
||||
destination = MediaSelectionDestination.MultipleRecipients(recipientIds),
|
||||
message = message
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildIntent(
|
||||
context: Context,
|
||||
startAction: Int = -1,
|
||||
transportOption: TransportOption = TransportOptions.getPushTransportOption(context),
|
||||
media: List<Media> = listOf(),
|
||||
destination: MediaSelectionDestination = MediaSelectionDestination.ChooseAfterMediaSelection,
|
||||
message: CharSequence? = null,
|
||||
isReply: Boolean = false
|
||||
): Intent {
|
||||
return Intent(context, MediaSelectionActivity::class.java).apply {
|
||||
putExtra(START_ACTION, startAction)
|
||||
putExtra(TRANSPORT_OPTION, transportOption)
|
||||
putParcelableArrayListExtra(MEDIA, ArrayList(media))
|
||||
putExtra(MESSAGE, message)
|
||||
putExtra(DESTINATION, destination.toBundle())
|
||||
putExtra(IS_REPLY, isReply)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.os.Bundle
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
sealed class MediaSelectionDestination {
|
||||
|
||||
object Wallpaper : MediaSelectionDestination() {
|
||||
override fun toBundle(): Bundle {
|
||||
return Bundle().apply {
|
||||
putBoolean(WALLPAPER, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Avatar : MediaSelectionDestination() {
|
||||
override fun toBundle(): Bundle {
|
||||
return Bundle().apply {
|
||||
putBoolean(AVATAR, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ChooseAfterMediaSelection : MediaSelectionDestination() {
|
||||
override fun toBundle(): Bundle {
|
||||
return Bundle.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
class SingleRecipient(private val id: RecipientId) : MediaSelectionDestination() {
|
||||
override fun getRecipientId(): RecipientId = id
|
||||
|
||||
override fun toBundle(): Bundle {
|
||||
return Bundle().apply {
|
||||
putParcelable(RECIPIENT, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultipleRecipients(val recipientIds: List<RecipientId>) : MediaSelectionDestination() {
|
||||
override fun getRecipientIdList(): List<RecipientId> = recipientIds
|
||||
|
||||
override fun toBundle(): Bundle {
|
||||
return Bundle().apply {
|
||||
putParcelableArrayList(RECIPIENT_LIST, ArrayList(recipientIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun getRecipientId(): RecipientId? = null
|
||||
open fun getRecipientIdList(): List<RecipientId> = emptyList()
|
||||
|
||||
abstract fun toBundle(): Bundle
|
||||
|
||||
companion object {
|
||||
private const val WALLPAPER = "wallpaper"
|
||||
private const val AVATAR = "avatar"
|
||||
private const val RECIPIENT = "recipient"
|
||||
private const val RECIPIENT_LIST = "recipient_list"
|
||||
|
||||
fun fromBundle(bundle: Bundle): MediaSelectionDestination {
|
||||
return when {
|
||||
bundle.containsKey(WALLPAPER) -> Wallpaper
|
||||
bundle.containsKey(AVATAR) -> Avatar
|
||||
bundle.containsKey(RECIPIENT) -> SingleRecipient(requireNotNull(bundle.getParcelable(RECIPIENT)))
|
||||
bundle.containsKey(RECIPIENT_LIST) -> MultipleRecipients(requireNotNull(bundle.getParcelableArrayList(RECIPIENT_LIST)))
|
||||
else -> ChooseAfterMediaSelection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.Manifest
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.Navigation
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
|
||||
class MediaSelectionNavigator(
|
||||
private val toCamera: Int = -1,
|
||||
private val toGallery: Int = -1
|
||||
) {
|
||||
fun goToReview(view: View) {
|
||||
Navigation.findNavController(view).popBackStack(R.id.mediaReviewFragment, false)
|
||||
}
|
||||
|
||||
fun goToCamera(view: View) {
|
||||
if (toCamera == -1) return
|
||||
|
||||
Navigation.findNavController(view).navigate(toCamera)
|
||||
}
|
||||
|
||||
fun goToGallery(view: View) {
|
||||
if (toGallery == -1) return
|
||||
|
||||
Navigation.findNavController(view).navigate(toGallery)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun Fragment.requestPermissionsForCamera(
|
||||
onGranted: () -> Unit
|
||||
) {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24)
|
||||
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
|
||||
.onAllGranted(onGranted)
|
||||
.onAnyDenied { Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show() }
|
||||
.execute()
|
||||
}
|
||||
|
||||
fun Fragment.requestPermissionsForGallery(
|
||||
onGranted: () -> Unit
|
||||
) {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(getString(R.string.AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos))
|
||||
.onAllGranted(onGranted)
|
||||
.onAnyDenied { Toast.makeText(this.requireContext(), R.string.AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos, Toast.LENGTH_LONG).show() }
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.TransportOption
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel
|
||||
import org.thoughtcrime.securesms.mediasend.CompositeMediaTransform
|
||||
import org.thoughtcrime.securesms.mediasend.ImageEditorModelRenderMediaTransform
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
|
||||
import org.thoughtcrime.securesms.mediasend.MediaTransform
|
||||
import org.thoughtcrime.securesms.mediasend.MediaUploadRepository
|
||||
import org.thoughtcrime.securesms.mediasend.SentMediaQualityTransform
|
||||
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
|
||||
import org.thoughtcrime.securesms.mediasend.VideoTrimTransform
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
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.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult
|
||||
import org.thoughtcrime.securesms.util.MessageUtil
|
||||
import java.util.ArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = Log.tag(MediaSelectionRepository::class.java)
|
||||
|
||||
class MediaSelectionRepository(context: Context) {
|
||||
|
||||
private val context: Context = context.applicationContext
|
||||
|
||||
private val mediaRepository = MediaRepository()
|
||||
|
||||
val uploadRepository = MediaUploadRepository(context)
|
||||
val isMetered: Observable<Boolean> = MeteredConnectivity.isMetered(context)
|
||||
|
||||
fun populateAndFilterMedia(media: List<Media>, mediaConstraints: MediaConstraints, maxSelection: Int): Single<MediaValidator.FilterResult> {
|
||||
return Single.fromCallable {
|
||||
val populatedMedia = mediaRepository.getPopulatedMedia(context, media)
|
||||
|
||||
MediaValidator.filterMedia(context, populatedMedia, mediaConstraints, maxSelection)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to send the selected media, performing proper transformations for edited images and videos.
|
||||
*/
|
||||
fun send(
|
||||
selectedMedia: List<Media>,
|
||||
stateMap: Map<Uri, Any>,
|
||||
quality: SentMediaQuality,
|
||||
message: CharSequence?,
|
||||
isSms: Boolean,
|
||||
isViewOnce: Boolean,
|
||||
singleRecipientId: RecipientId?,
|
||||
recipientIds: List<RecipientId>,
|
||||
mentions: List<Mention>,
|
||||
transport: TransportOption
|
||||
): Maybe<MediaSendActivityResult> {
|
||||
if (isSms && recipientIds.isNotEmpty()) {
|
||||
throw IllegalStateException("Provided recipients to send to, but this is SMS!")
|
||||
}
|
||||
|
||||
return Maybe.create<MediaSendActivityResult> { emitter ->
|
||||
val trimmedBody: String = if (isViewOnce) "" else message?.toString()?.trim() ?: ""
|
||||
val trimmedMentions: List<Mention> = if (isViewOnce) emptyList() else mentions
|
||||
val modelsToTransform: Map<Media, MediaTransform> = buildModelsToTransform(selectedMedia, stateMap, quality)
|
||||
val oldToNewMediaMap: Map<Media, Media> = MediaRepository.transformMediaSync(context, selectedMedia, modelsToTransform)
|
||||
val updatedMedia = oldToNewMediaMap.values.toList()
|
||||
|
||||
for (media in updatedMedia) {
|
||||
Log.w(TAG, media.uri.toString() + " : " + media.transformProperties.transform { t: TransformProperties -> "" + t.isVideoTrim }.or("null"))
|
||||
}
|
||||
|
||||
val singleRecipient = singleRecipientId?.let { Recipient.resolved(it) }
|
||||
if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, isSms)) {
|
||||
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.")
|
||||
emitter.onSuccess(MediaSendActivityResult.forTraditionalSend(requireNotNull(singleRecipient).id, updatedMedia, trimmedBody, transport, isViewOnce, trimmedMentions))
|
||||
} else {
|
||||
val splitMessage = MessageUtil.getSplitMessage(context, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize)
|
||||
val splitBody = splitMessage.body
|
||||
|
||||
if (splitMessage.textSlide.isPresent) {
|
||||
val slide: Slide = splitMessage.textSlide.get()
|
||||
uploadRepository.startUpload(
|
||||
MediaBuilder.buildMedia(
|
||||
uri = requireNotNull(slide.uri),
|
||||
mimeType = slide.contentType,
|
||||
date = System.currentTimeMillis(),
|
||||
size = slide.fileSize,
|
||||
borderless = slide.isBorderless,
|
||||
videoGif = slide.isVideoGif
|
||||
),
|
||||
singleRecipient
|
||||
)
|
||||
}
|
||||
|
||||
uploadRepository.applyMediaUpdates(oldToNewMediaMap, singleRecipient)
|
||||
uploadRepository.updateCaptions(updatedMedia)
|
||||
uploadRepository.updateDisplayOrder(updatedMedia)
|
||||
uploadRepository.getPreUploadResults { uploadResults ->
|
||||
if (recipientIds.isNotEmpty()) {
|
||||
val recipients = recipientIds.map { Recipient.resolved(it) }
|
||||
sendMessages(recipients, splitBody, uploadResults, trimmedMentions, isViewOnce)
|
||||
uploadRepository.deleteAbandonedAttachments()
|
||||
emitter.onComplete()
|
||||
} else {
|
||||
emitter.onSuccess(MediaSendActivityResult.forPreUpload(requireNotNull(singleRecipient).id, uploadResults, splitBody, transport, isViewOnce, trimmedMentions))
|
||||
}
|
||||
}
|
||||
}
|
||||
}.subscribeOn(Schedulers.io()).cast(MediaSendActivityResult::class.java)
|
||||
}
|
||||
|
||||
fun deleteBlobs(media: List<Media>) {
|
||||
media
|
||||
.map(Media::getUri)
|
||||
.filter(BlobProvider::isAuthority)
|
||||
.forEach { BlobProvider.getInstance().delete(context, it) }
|
||||
}
|
||||
|
||||
fun cleanUp(selectedMedia: List<Media>) {
|
||||
deleteBlobs(selectedMedia)
|
||||
uploadRepository.cancelAllUploads()
|
||||
uploadRepository.deleteAbandonedAttachments()
|
||||
}
|
||||
|
||||
fun isLocalSelfSend(recipient: Recipient?, isSms: Boolean): Boolean {
|
||||
return !MessageSender.isLocalSelfSend(context, recipient, isSms)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun buildModelsToTransform(
|
||||
selectedMedia: List<Media>,
|
||||
stateMap: Map<Uri, Any>,
|
||||
quality: SentMediaQuality
|
||||
): Map<Media, MediaTransform> {
|
||||
val modelsToRender: MutableMap<Media, MediaTransform> = mutableMapOf()
|
||||
|
||||
selectedMedia.forEach {
|
||||
val state = stateMap[it.uri]
|
||||
if (state is ImageEditorFragment.Data) {
|
||||
val model: EditorModel? = state.readModel()
|
||||
if (model != null && model.isChanged) {
|
||||
modelsToRender[it] = ImageEditorModelRenderMediaTransform(model)
|
||||
}
|
||||
}
|
||||
|
||||
if (state is VideoEditorFragment.Data && state.isDurationEdited) {
|
||||
modelsToRender[it] = VideoTrimTransform(state)
|
||||
}
|
||||
|
||||
if (quality == SentMediaQuality.HIGH) {
|
||||
val existingTransform: MediaTransform? = modelsToRender[it]
|
||||
|
||||
modelsToRender[it] = if (existingTransform == null) {
|
||||
SentMediaQualityTransform(quality)
|
||||
} else {
|
||||
CompositeMediaTransform(existingTransform, SentMediaQualityTransform(quality))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modelsToRender
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun sendMessages(recipients: List<Recipient>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
|
||||
val messages: MutableList<OutgoingSecureMediaMessage> = ArrayList(recipients.size)
|
||||
|
||||
for (recipient in recipients) {
|
||||
val message = OutgoingMediaMessage(
|
||||
recipient,
|
||||
body, emptyList(),
|
||||
System.currentTimeMillis(),
|
||||
-1,
|
||||
TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
|
||||
isViewOnce,
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
null, emptyList(), emptyList(),
|
||||
mentions, emptyList(), emptyList()
|
||||
)
|
||||
messages.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.
|
||||
ThreadUtil.sleep(5)
|
||||
}
|
||||
|
||||
MessageSender.sendMediaBroadcast(context, messages, preUploadResults)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.TransportOption
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendConstants
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class MediaSelectionState(
|
||||
val transportOption: TransportOption,
|
||||
val selectedMedia: List<Media> = listOf(),
|
||||
val focusedMedia: Media? = null,
|
||||
val recipient: Recipient? = null,
|
||||
val quality: SentMediaQuality = SentMediaQuality.STANDARD,
|
||||
val message: CharSequence? = null,
|
||||
val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.INFINITE,
|
||||
val isTouchEnabled: Boolean = true,
|
||||
val isSent: Boolean = false,
|
||||
val isPreUploadEnabled: Boolean = false,
|
||||
val isMeteredConnection: Boolean = false,
|
||||
val editorStateMap: Map<Uri, Any> = mapOf(),
|
||||
val cameraFirstCapture: Media? = null
|
||||
) {
|
||||
|
||||
val maxSelection = if (transportOption.isSms) {
|
||||
MediaSendConstants.MAX_SMS
|
||||
} else {
|
||||
MediaSendConstants.MAX_PUSH
|
||||
}
|
||||
|
||||
enum class ViewOnceToggleState(val code: Int) {
|
||||
INFINITE(0),
|
||||
ONCE(1);
|
||||
|
||||
fun next(): ViewOnceToggleState {
|
||||
return when (this) {
|
||||
INFINITE -> ONCE
|
||||
ONCE -> INFINITE
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int): ViewOnceToggleState {
|
||||
return when (code) {
|
||||
1 -> ONCE
|
||||
else -> INFINITE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
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.subjects.PublishSubject
|
||||
import org.thoughtcrime.securesms.TransportOption
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
|
||||
import org.thoughtcrime.securesms.mediasend.VideoEditorFragment
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
/**
|
||||
* ViewModel which maintains the list of selected media and other shared values.
|
||||
*/
|
||||
class MediaSelectionViewModel(
|
||||
val destination: MediaSelectionDestination,
|
||||
transportOption: TransportOption,
|
||||
initialMedia: List<Media>,
|
||||
initialMessage: CharSequence?,
|
||||
val isReply: Boolean,
|
||||
private val repository: MediaSelectionRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store: Store<MediaSelectionState> = Store(
|
||||
MediaSelectionState(
|
||||
transportOption = transportOption,
|
||||
message = initialMessage
|
||||
)
|
||||
)
|
||||
|
||||
val isContactSelectionRequired = destination == MediaSelectionDestination.ChooseAfterMediaSelection
|
||||
|
||||
val state: LiveData<MediaSelectionState> = store.stateLiveData
|
||||
|
||||
private val internalHudCommands = PublishSubject.create<HudCommand>()
|
||||
private val internalFilterErrors = PublishSubject.create<MediaValidator.FilterError>()
|
||||
|
||||
val hudCommands: Observable<HudCommand> = internalHudCommands
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val isMeteredDisposable: Disposable = repository.isMetered.subscribe { metered ->
|
||||
store.update {
|
||||
it.copy(
|
||||
isMeteredConnection = metered,
|
||||
isPreUploadEnabled = shouldPreUpload(metered, it.transportOption.isSms, it.recipient)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val recipientId = destination.getRecipientId()
|
||||
if (recipientId != null) {
|
||||
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(recipient = r) }
|
||||
}
|
||||
|
||||
if (initialMedia.isNotEmpty()) {
|
||||
addMedia(initialMedia)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
isMeteredDisposable.dispose()
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun kick() {
|
||||
store.update { it }
|
||||
}
|
||||
|
||||
fun sendCommand(hudCommand: HudCommand) {
|
||||
internalHudCommands.onNext(hudCommand)
|
||||
}
|
||||
|
||||
fun setTouchEnabled(isEnabled: Boolean) {
|
||||
store.update { it.copy(isTouchEnabled = isEnabled) }
|
||||
}
|
||||
|
||||
fun addMedia(media: Media) {
|
||||
addMedia(listOf(media))
|
||||
}
|
||||
|
||||
private fun addMedia(media: List<Media>) {
|
||||
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
|
||||
addAll(store.state.selectedMedia)
|
||||
addAll(media)
|
||||
}.toList()
|
||||
|
||||
disposables.add(
|
||||
repository
|
||||
.populateAndFilterMedia(newSelectionList, getMediaConstraints(), store.state.maxSelection)
|
||||
.subscribe { filterResult ->
|
||||
if (filterResult.filteredMedia.isNotEmpty()) {
|
||||
store.update {
|
||||
it.copy(
|
||||
selectedMedia = filterResult.filteredMedia,
|
||||
focusedMedia = it.focusedMedia ?: filterResult.filteredMedia.first()
|
||||
)
|
||||
}
|
||||
|
||||
val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList()
|
||||
startUpload(newMedia)
|
||||
}
|
||||
|
||||
if (filterResult.filterError != null) {
|
||||
internalFilterErrors.onNext(filterResult.filterError)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun removeMedia(media: Media) {
|
||||
val snapshot = store.state
|
||||
val newMediaList = snapshot.selectedMedia - media
|
||||
val oldFocusIndex = snapshot.selectedMedia.indexOf(media)
|
||||
val newFocus = when {
|
||||
newMediaList.isEmpty() -> null
|
||||
media == snapshot.focusedMedia -> newMediaList[Util.clamp(oldFocusIndex, 0, newMediaList.size - 1)]
|
||||
else -> snapshot.focusedMedia
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(
|
||||
selectedMedia = newMediaList,
|
||||
focusedMedia = newFocus,
|
||||
editorStateMap = it.editorStateMap - media.uri,
|
||||
cameraFirstCapture = if (media == it.cameraFirstCapture) null else it.cameraFirstCapture
|
||||
)
|
||||
}
|
||||
|
||||
if (newMediaList.isEmpty()) {
|
||||
internalFilterErrors.onNext(MediaValidator.FilterError.NO_ITEMS)
|
||||
}
|
||||
|
||||
repository.deleteBlobs(listOf(media))
|
||||
|
||||
cancelUpload(media)
|
||||
}
|
||||
|
||||
fun addCameraFirstCapture(media: Media) {
|
||||
store.update { state ->
|
||||
state.copy(cameraFirstCapture = media)
|
||||
}
|
||||
addMedia(media)
|
||||
}
|
||||
|
||||
fun removeCameraFirstCapture() {
|
||||
val cameraFirstCapture: Media? = store.state.cameraFirstCapture
|
||||
if (cameraFirstCapture != null) {
|
||||
removeMedia(cameraFirstCapture)
|
||||
}
|
||||
}
|
||||
|
||||
fun setFocusedMedia(media: Media) {
|
||||
store.update { it.copy(focusedMedia = media) }
|
||||
}
|
||||
|
||||
fun setFocusedMedia(position: Int) {
|
||||
store.update {
|
||||
if (position >= it.selectedMedia.size) {
|
||||
it.copy(focusedMedia = null)
|
||||
} else {
|
||||
it.copy(focusedMedia = it.selectedMedia[position])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaConstraints(): MediaConstraints {
|
||||
return if (store.state.transportOption.isSms) {
|
||||
MediaConstraints.getMmsMediaConstraints(store.state.transportOption.simSubscriptionId.or(-1))
|
||||
} else {
|
||||
MediaConstraints.getPushMediaConstraints()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSentMediaQuality(sentMediaQuality: SentMediaQuality) {
|
||||
if (sentMediaQuality == store.state.quality) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(quality = sentMediaQuality, isPreUploadEnabled = false) }
|
||||
repository.uploadRepository.cancelAllUploads()
|
||||
}
|
||||
|
||||
fun setMessage(text: CharSequence?) {
|
||||
store.update { it.copy(message = text) }
|
||||
}
|
||||
|
||||
fun incrementViewOnceState() {
|
||||
store.update { it.copy(viewOnceToggleState = it.viewOnceToggleState.next()) }
|
||||
}
|
||||
|
||||
fun getEditorState(uri: Uri): Any? {
|
||||
return store.state.editorStateMap[uri]
|
||||
}
|
||||
|
||||
fun setEditorState(uri: Uri, state: Any) {
|
||||
store.update {
|
||||
it.copy(editorStateMap = it.editorStateMap + (uri to state))
|
||||
}
|
||||
}
|
||||
|
||||
fun onVideoBeginEdit(uri: Uri) {
|
||||
cancelUpload(MediaBuilder.buildMedia(uri))
|
||||
}
|
||||
|
||||
fun send(
|
||||
selectedRecipientIds: List<RecipientId> = emptyList(),
|
||||
): Maybe<MediaSendActivityResult> {
|
||||
return repository.send(
|
||||
store.state.selectedMedia,
|
||||
store.state.editorStateMap,
|
||||
store.state.quality,
|
||||
store.state.message,
|
||||
store.state.transportOption.isSms,
|
||||
isViewOnceEnabled(),
|
||||
destination.getRecipientId(),
|
||||
if (selectedRecipientIds.isNotEmpty()) selectedRecipientIds else destination.getRecipientIdList(),
|
||||
emptyList(), // TODO [alex] -- mentions
|
||||
store.state.transportOption
|
||||
)
|
||||
}
|
||||
|
||||
private fun isViewOnceEnabled(): Boolean {
|
||||
return !store.state.transportOption.isSms &&
|
||||
store.state.selectedMedia.size == 1 &&
|
||||
store.state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE
|
||||
}
|
||||
|
||||
private fun startUpload(media: List<Media>) {
|
||||
if (!store.state.isPreUploadEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
repository.uploadRepository.startUpload(media, store.state.recipient)
|
||||
}
|
||||
|
||||
private fun cancelUpload(media: Media) {
|
||||
repository.uploadRepository.cancelUpload(media)
|
||||
}
|
||||
|
||||
private fun shouldPreUpload(metered: Boolean, isSms: Boolean, recipient: Recipient?): Boolean {
|
||||
return !metered && !isSms && !repository.isLocalSelfSend(recipient, isSms)
|
||||
}
|
||||
|
||||
fun onSaveState(outState: Bundle) {
|
||||
val snapshot = store.state
|
||||
|
||||
outState.putParcelableArrayList(STATE_SELECTION, ArrayList(snapshot.selectedMedia))
|
||||
outState.putParcelable(STATE_FOCUSED, snapshot.focusedMedia)
|
||||
outState.putInt(STATE_QUALITY, snapshot.quality.code)
|
||||
outState.putCharSequence(STATE_MESSAGE, snapshot.message)
|
||||
outState.putInt(STATE_VIEW_ONCE, snapshot.viewOnceToggleState.code)
|
||||
outState.putBoolean(STATE_TOUCH_ENABLED, snapshot.isTouchEnabled)
|
||||
outState.putBoolean(STATE_SENT, snapshot.isSent)
|
||||
outState.putParcelable(STATE_CAMERA_FIRST_CAPTURE, snapshot.cameraFirstCapture)
|
||||
|
||||
val editorStates: List<Bundle> = store.state.editorStateMap.entries.map { it.toBundleStateEntry() }
|
||||
outState.putParcelableArrayList(STATE_EDITORS, ArrayList(editorStates))
|
||||
}
|
||||
|
||||
fun onRestoreState(savedInstanceState: Bundle) {
|
||||
val selection: List<Media> = savedInstanceState.getParcelableArrayList(STATE_SELECTION) ?: emptyList()
|
||||
val focused: Media? = savedInstanceState.getParcelable(STATE_FOCUSED)
|
||||
val quality: SentMediaQuality = SentMediaQuality.fromCode(savedInstanceState.getInt(STATE_QUALITY))
|
||||
val message: CharSequence? = savedInstanceState.getCharSequence(STATE_MESSAGE)
|
||||
val viewOnce: MediaSelectionState.ViewOnceToggleState = MediaSelectionState.ViewOnceToggleState.fromCode(savedInstanceState.getInt(STATE_VIEW_ONCE))
|
||||
val touchEnabled: Boolean = savedInstanceState.getBoolean(STATE_TOUCH_ENABLED)
|
||||
val sent: Boolean = savedInstanceState.getBoolean(STATE_SENT)
|
||||
val cameraFirstCapture: Media? = savedInstanceState.getParcelable(STATE_CAMERA_FIRST_CAPTURE)
|
||||
|
||||
val editorStates: List<Bundle> = savedInstanceState.getParcelableArrayList(STATE_EDITORS) ?: emptyList()
|
||||
val editorStateMap = editorStates.associate { it.toAssociation() }
|
||||
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
selectedMedia = selection,
|
||||
focusedMedia = focused,
|
||||
quality = quality,
|
||||
message = message,
|
||||
viewOnceToggleState = viewOnce,
|
||||
isTouchEnabled = touchEnabled,
|
||||
isSent = sent,
|
||||
cameraFirstCapture = cameraFirstCapture,
|
||||
editorStateMap = editorStateMap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Bundle.toAssociation(): Pair<Uri, Any> {
|
||||
val key: Uri = requireNotNull(getParcelable(BUNDLE_URI))
|
||||
|
||||
val value: Any = if (getBoolean(BUNDLE_IS_IMAGE)) {
|
||||
ImageEditorFragment.Data(this)
|
||||
} else {
|
||||
VideoEditorFragment.Data.fromBundle(this)
|
||||
}
|
||||
|
||||
return key to value
|
||||
}
|
||||
|
||||
private fun Map.Entry<Uri, Any>.toBundleStateEntry(): Bundle {
|
||||
return when (val value = this.value) {
|
||||
is ImageEditorFragment.Data -> {
|
||||
value.bundle.apply {
|
||||
putParcelable(BUNDLE_URI, key)
|
||||
putBoolean(BUNDLE_IS_IMAGE, true)
|
||||
}
|
||||
}
|
||||
is VideoEditorFragment.Data -> {
|
||||
value.bundle.apply {
|
||||
putParcelable(BUNDLE_URI, key)
|
||||
putBoolean(BUNDLE_IS_IMAGE, false)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STATE_PREFIX = "selection.view.model"
|
||||
|
||||
private const val BUNDLE_URI = "$STATE_PREFIX.uri"
|
||||
private const val BUNDLE_IS_IMAGE = "$STATE_PREFIX.is_image"
|
||||
private const val STATE_SELECTION = "$STATE_PREFIX.selection"
|
||||
private const val STATE_FOCUSED = "$STATE_PREFIX.focused"
|
||||
private const val STATE_QUALITY = "$STATE_PREFIX.quality"
|
||||
private const val STATE_MESSAGE = "$STATE_PREFIX.message"
|
||||
private const val STATE_VIEW_ONCE = "$STATE_PREFIX.viewOnce"
|
||||
private const val STATE_TOUCH_ENABLED = "$STATE_PREFIX.touchEnabled"
|
||||
private const val STATE_SENT = "$STATE_PREFIX.sent"
|
||||
private const val STATE_CAMERA_FIRST_CAPTURE = "$STATE_PREFIX.camera_first_capture"
|
||||
private const val STATE_EDITORS = "$STATE_PREFIX.editors"
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val destination: MediaSelectionDestination,
|
||||
private val transportOption: TransportOption,
|
||||
private val initialMedia: List<Media>,
|
||||
private val initialMessage: CharSequence?,
|
||||
private val isReply: Boolean,
|
||||
private val repository: MediaSelectionRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(MediaSelectionViewModel(destination, transportOption, initialMedia, initialMessage, isReply, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.content.Context
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
object MediaValidator {
|
||||
|
||||
fun filterMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints, maxSelection: Int): FilterResult {
|
||||
val filteredMedia = filterForValidMedia(context, media, mediaConstraints)
|
||||
val isAllMediaValid = filteredMedia.size == media.size
|
||||
|
||||
var error: FilterError? = null
|
||||
if (!isAllMediaValid) {
|
||||
error = if (media.all { MediaUtil.isImageOrVideoType(it.mimeType) }) {
|
||||
FilterError.ITEM_TOO_LARGE
|
||||
} else {
|
||||
FilterError.ITEM_INVALID_TYPE
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredMedia.size > maxSelection) {
|
||||
error = FilterError.TOO_MANY_ITEMS
|
||||
}
|
||||
|
||||
val truncatedMedia = filteredMedia.take(maxSelection)
|
||||
val bucketId = if (truncatedMedia.isNotEmpty()) {
|
||||
truncatedMedia.drop(1).fold(truncatedMedia.first().bucketId.or(Media.ALL_MEDIA_BUCKET_ID)) { acc, media ->
|
||||
if (Util.equals(acc, media.bucketId.or(Media.ALL_MEDIA_BUCKET_ID))) {
|
||||
acc
|
||||
} else {
|
||||
Media.ALL_MEDIA_BUCKET_ID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Media.ALL_MEDIA_BUCKET_ID
|
||||
}
|
||||
|
||||
if (truncatedMedia.isEmpty()) {
|
||||
error = FilterError.NO_ITEMS
|
||||
}
|
||||
|
||||
return FilterResult(truncatedMedia, error, bucketId)
|
||||
}
|
||||
|
||||
private fun filterForValidMedia(context: Context, media: List<Media>, mediaConstraints: MediaConstraints): List<Media> {
|
||||
return media
|
||||
.filter { m -> isSupportedMediaType(m.mimeType) }
|
||||
.filter { m ->
|
||||
MediaUtil.isImageAndNotGif(m.mimeType) || isValidGif(context, m, mediaConstraints) || isValidVideo(context, m, mediaConstraints)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidGif(context: Context, media: Media, mediaConstraints: MediaConstraints): Boolean {
|
||||
return MediaUtil.isGif(media.mimeType) && media.size < mediaConstraints.getGifMaxSize(context)
|
||||
}
|
||||
|
||||
private fun isValidVideo(context: Context, media: Media, mediaConstraints: MediaConstraints): Boolean {
|
||||
return MediaUtil.isVideoType(media.mimeType) && media.size < mediaConstraints.getUncompressedVideoMaxSize(context)
|
||||
}
|
||||
|
||||
private fun isSupportedMediaType(mimeType: String): Boolean {
|
||||
return MediaUtil.isGif(mimeType) || MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)
|
||||
}
|
||||
|
||||
data class FilterResult(val filteredMedia: List<Media>, val filterError: FilterError?, val bucketId: String?)
|
||||
|
||||
enum class FilterError {
|
||||
ITEM_TOO_LARGE,
|
||||
ITEM_INVALID_TYPE,
|
||||
TOO_MANY_ITEMS,
|
||||
NO_ITEMS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import androidx.core.net.ConnectivityManagerCompat
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
object MeteredConnectivity {
|
||||
fun isMetered(context: Context): Observable<Boolean> = Observable.create { emitter ->
|
||||
val connectivityManager = ServiceUtil.getConnectivityManager(context)
|
||||
|
||||
emitter.onNext(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager))
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
emitter.onNext(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager))
|
||||
}
|
||||
}
|
||||
|
||||
context.registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
|
||||
|
||||
emitter.setCancellable {
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.capture
|
||||
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
|
||||
sealed class MediaCaptureEvent {
|
||||
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent()
|
||||
object MediaCaptureRenderFailed : MediaCaptureEvent()
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.capture
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.LiveData
|
||||
import app.cash.exhaustive.Exhaustive
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.CameraFragment
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForGallery
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import java.io.FileDescriptor
|
||||
|
||||
private val TAG = Log.tag(MediaCaptureFragment::class.java)
|
||||
|
||||
/**
|
||||
* Fragment which displays the proper camera fragment.
|
||||
*/
|
||||
class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragment.Controller {
|
||||
|
||||
private val sharedViewModel: MediaSelectionViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
private val viewModel: MediaCaptureViewModel by viewModels(
|
||||
factoryProducer = { MediaCaptureViewModel.Factory(MediaCaptureRepository(requireContext())) }
|
||||
)
|
||||
|
||||
private lateinit var captureChildFragment: CameraFragment
|
||||
private lateinit var navigator: MediaSelectionNavigator
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
captureChildFragment = CameraFragment.newInstance() as CameraFragment
|
||||
|
||||
navigator = MediaSelectionNavigator(
|
||||
toGallery = R.id.action_mediaCaptureFragment_to_mediaGalleryFragment
|
||||
)
|
||||
|
||||
childFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, captureChildFragment as Fragment)
|
||||
.commitNowAllowingStateLoss()
|
||||
|
||||
viewModel.events.observe(viewLifecycleOwner) { event ->
|
||||
@Exhaustive
|
||||
when (event) {
|
||||
MediaCaptureEvent.MediaCaptureRenderFailed -> {
|
||||
Log.w(TAG, "Failed to render captured media.")
|
||||
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
is MediaCaptureEvent.MediaCaptureRendered -> {
|
||||
captureChildFragment.fadeOutControls {
|
||||
if (isFirst()) {
|
||||
sharedViewModel.addCameraFirstCapture(event.media)
|
||||
} else {
|
||||
sharedViewModel.addMedia(event.media)
|
||||
}
|
||||
|
||||
navigator.goToReview(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
captureChildFragment.presentHud(state.selectedMedia.size)
|
||||
}
|
||||
|
||||
if (isFirst()) {
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
captureChildFragment.fadeInControls()
|
||||
}
|
||||
|
||||
override fun onCameraError() {
|
||||
Log.w(TAG, "Camera Error.")
|
||||
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
|
||||
viewModel.onImageCaptured(data, width, height)
|
||||
}
|
||||
|
||||
override fun onVideoCaptured(fd: FileDescriptor) {
|
||||
viewModel.onVideoCaptured(fd)
|
||||
}
|
||||
|
||||
override fun onVideoCaptureError() {
|
||||
Log.w(TAG, "Video capture error.")
|
||||
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onGalleryClicked() {
|
||||
requestPermissionsForGallery {
|
||||
captureChildFragment.fadeOutControls {
|
||||
navigator.goToGallery(requireView())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDisplayRotation(): Int {
|
||||
return if (Build.VERSION.SDK_INT >= 30) {
|
||||
requireContext().display?.rotation ?: 0
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
requireActivity().windowManager.defaultDisplay.rotation
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCameraCountButtonClicked() {
|
||||
captureChildFragment.fadeOutControls {
|
||||
navigator.goToReview(requireView())
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMostRecentMediaItem(): LiveData<Optional<Media>> {
|
||||
return viewModel.getMostRecentMedia()
|
||||
}
|
||||
|
||||
override fun getMediaConstraints(): MediaConstraints {
|
||||
return sharedViewModel.getMediaConstraints()
|
||||
}
|
||||
|
||||
private fun isFirst(): Boolean {
|
||||
return arguments?.getBoolean("first") == true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CAPTURE_RESULT = "capture_result"
|
||||
const val CAPTURE_RESULT_OK = "capture_result_ok"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.capture
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.StorageUtil
|
||||
import org.thoughtcrime.securesms.video.VideoUtil
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import java.io.FileDescriptor
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.util.LinkedList
|
||||
|
||||
private val TAG = Log.tag(MediaCaptureRepository::class.java)
|
||||
|
||||
class MediaCaptureRepository(context: Context) {
|
||||
|
||||
private val context: Context = context.applicationContext
|
||||
|
||||
fun getMostRecentItem(callback: (Media?) -> Unit) {
|
||||
if (!StorageUtil.canReadFromMediaStore()) {
|
||||
Log.w(TAG, "Cannot read from storage.")
|
||||
callback(null)
|
||||
}
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val media: List<Media> = getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||
callback(media.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
fun renderImageToMedia(data: ByteArray, width: Int, height: Int, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val media: Media? = renderCaptureToMedia(
|
||||
dataSupplier = { data },
|
||||
getLength = { data.size.toLong() },
|
||||
createBlobBuilder = { blobProvider, bytes, _ -> blobProvider.forData(bytes) },
|
||||
mimeType = MediaUtil.IMAGE_JPEG,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
|
||||
if (media != null) {
|
||||
onMediaRendered(media)
|
||||
} else {
|
||||
onFailedToRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun renderVideoToMedia(fileDescriptor: FileDescriptor, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val media: Media? = renderCaptureToMedia(
|
||||
dataSupplier = { FileInputStream(fileDescriptor) },
|
||||
getLength = { it.channel.size() },
|
||||
createBlobBuilder = BlobProvider::forData,
|
||||
mimeType = VideoUtil.RECORDED_VIDEO_CONTENT_TYPE,
|
||||
width = 0,
|
||||
height = 0
|
||||
)
|
||||
|
||||
if (media != null) {
|
||||
onMediaRendered(media)
|
||||
} else {
|
||||
onFailedToRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> renderCaptureToMedia(
|
||||
dataSupplier: () -> T,
|
||||
getLength: (T) -> Long,
|
||||
createBlobBuilder: (BlobProvider, T, Long) -> BlobProvider.BlobBuilder,
|
||||
mimeType: String,
|
||||
width: Int,
|
||||
height: Int
|
||||
): Media? {
|
||||
return try {
|
||||
val data: T = dataSupplier()
|
||||
val length: Long = getLength(data)
|
||||
val uri: Uri = createBlobBuilder(BlobProvider.getInstance(), data, length)
|
||||
.withMimeType(mimeType)
|
||||
.createForSingleSessionOnDisk(context)
|
||||
|
||||
Media(
|
||||
uri,
|
||||
mimeType,
|
||||
System.currentTimeMillis(),
|
||||
width,
|
||||
height,
|
||||
length,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
Optional.of(Media.ALL_MEDIA_BUCKET_ID),
|
||||
Optional.absent(),
|
||||
Optional.absent()
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@SuppressLint("VisibleForTests")
|
||||
@WorkerThread
|
||||
private fun getMediaInBucket(context: Context, bucketId: String, contentUri: Uri, isImage: Boolean): List<Media> {
|
||||
val media: MutableList<Media> = LinkedList()
|
||||
var selection: String? = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending()
|
||||
var selectionArgs: Array<String>? = arrayOf(bucketId)
|
||||
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
|
||||
|
||||
val projection: Array<String> = if (isImage) {
|
||||
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.ORIENTATION, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE)
|
||||
} else {
|
||||
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE, MediaStore.Video.Media.DURATION)
|
||||
}
|
||||
|
||||
if (Media.ALL_MEDIA_BUCKET_ID == bucketId) {
|
||||
selection = isNotPending()
|
||||
selectionArgs = null
|
||||
}
|
||||
|
||||
context.contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val rowId = CursorUtil.requireLong(cursor, projection[0])
|
||||
val uri = ContentUris.withAppendedId(contentUri, rowId)
|
||||
val mimetype = CursorUtil.requireString(cursor, MediaStore.Images.Media.MIME_TYPE)
|
||||
val date = CursorUtil.requireLong(cursor, MediaStore.Images.Media.DATE_MODIFIED)
|
||||
val orientation = if (isImage) CursorUtil.requireInt(cursor, MediaStore.Images.Media.ORIENTATION) else 0
|
||||
val width = CursorUtil.requireInt(cursor, getWidthColumn(orientation))
|
||||
val height = CursorUtil.requireInt(cursor, getHeightColumn(orientation))
|
||||
val size = CursorUtil.requireLong(cursor, MediaStore.Images.Media.SIZE)
|
||||
val duration = if (!isImage) CursorUtil.requireInt(cursor, MediaStore.Video.Media.DURATION).toLong() else 0.toLong()
|
||||
media.add(
|
||||
MediaRepository.fixMimeType(
|
||||
context,
|
||||
Media(
|
||||
uri,
|
||||
mimetype,
|
||||
date,
|
||||
width,
|
||||
height,
|
||||
size,
|
||||
duration,
|
||||
false,
|
||||
false,
|
||||
Optional.of(bucketId),
|
||||
Optional.absent(),
|
||||
Optional.absent()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return media
|
||||
}
|
||||
|
||||
private fun getWidthColumn(orientation: Int): String {
|
||||
return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT
|
||||
}
|
||||
|
||||
private fun getHeightColumn(orientation: Int): String {
|
||||
return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isNotPending(): String {
|
||||
return if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.capture
|
||||
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
|
||||
data class MediaCaptureState(
|
||||
val mostRecentMedia: Media? = null
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.capture
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import java.io.FileDescriptor
|
||||
|
||||
class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : ViewModel() {
|
||||
|
||||
private val store: Store<MediaCaptureState> = Store(MediaCaptureState())
|
||||
|
||||
private val internalEvents: SingleLiveEvent<MediaCaptureEvent> = SingleLiveEvent()
|
||||
|
||||
val events: LiveData<MediaCaptureEvent> = internalEvents
|
||||
|
||||
init {
|
||||
repository.getMostRecentItem { media ->
|
||||
store.update { state ->
|
||||
state.copy(mostRecentMedia = media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
|
||||
repository.renderImageToMedia(data, width, height, this::onMediaRendered, this::onMediaRenderFailed)
|
||||
}
|
||||
|
||||
fun onVideoCaptured(fd: FileDescriptor) {
|
||||
repository.renderVideoToMedia(fd, this::onMediaRendered, this::onMediaRenderFailed)
|
||||
}
|
||||
|
||||
fun getMostRecentMedia(): LiveData<Optional<Media>> {
|
||||
return Transformations.map(store.stateLiveData) { Optional.fromNullable(it.mostRecentMedia) }
|
||||
}
|
||||
|
||||
private fun onMediaRendered(media: Media) {
|
||||
internalEvents.postValue(MediaCaptureEvent.MediaCaptureRendered(media))
|
||||
}
|
||||
|
||||
private fun onMediaRenderFailed() {
|
||||
internalEvents.postValue(MediaCaptureEvent.MediaCaptureRenderFailed)
|
||||
}
|
||||
|
||||
class Factory(private val repository: MediaCaptureRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(MediaCaptureViewModel(repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.gallery
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Displays a collection of files and folders to the user to allow them to select
|
||||
* media to send.
|
||||
*/
|
||||
class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
|
||||
|
||||
private val viewModel: MediaGalleryViewModel by viewModels(
|
||||
factoryProducer = { MediaGalleryViewModel.Factory(null, null, MediaGalleryRepository(requireContext(), MediaRepository())) }
|
||||
)
|
||||
|
||||
private lateinit var callbacks: Callbacks
|
||||
|
||||
private lateinit var toolbar: Toolbar
|
||||
private lateinit var galleryRecycler: RecyclerView
|
||||
private lateinit var countButton: MediaCountIndicatorButton
|
||||
private lateinit var bottomBarGroup: View
|
||||
private lateinit var selectedRecycler: RecyclerView
|
||||
|
||||
private val galleryAdapter = MappingAdapter()
|
||||
private val selectedAdapter = MappingAdapter()
|
||||
|
||||
private val viewStateLiveData = MutableLiveData(ViewState())
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
callbacks = requireNotNull(findListener())
|
||||
|
||||
toolbar = view.findViewById(R.id.media_gallery_toolbar)
|
||||
galleryRecycler = view.findViewById(R.id.media_gallery_grid)
|
||||
selectedRecycler = view.findViewById(R.id.media_gallery_selected)
|
||||
countButton = view.findViewById(R.id.media_gallery_count_button)
|
||||
bottomBarGroup = view.findViewById(R.id.media_gallery_bottom_bar_group)
|
||||
|
||||
(galleryRecycler.layoutManager as GridLayoutManager).spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
val isFolder: Boolean = (galleryRecycler.adapter as MappingAdapter).getModel(position).map { it is MediaGallerySelectableItem.FolderModel }.orElse(false)
|
||||
|
||||
return if (isFolder) 2 else 1
|
||||
}
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
if (viewModel.pop()) {
|
||||
callbacks.onToolbarNavigationClicked()
|
||||
}
|
||||
}
|
||||
|
||||
toolbar.setOnMenuItemClickListener { item ->
|
||||
if (item.itemId == R.id.action_camera) {
|
||||
callbacks.onNavigateToCamera()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
countButton.setOnClickListener {
|
||||
callbacks.onSubmit()
|
||||
}
|
||||
|
||||
MediaGallerySelectedItem.register(selectedAdapter) { media ->
|
||||
callbacks.onSelectedMediaClicked(media)
|
||||
}
|
||||
selectedRecycler.adapter = selectedAdapter
|
||||
|
||||
MediaGallerySelectableItem.registerAdapter(
|
||||
mappingAdapter = galleryAdapter,
|
||||
onMediaFolderClicked = {
|
||||
viewModel.setMediaFolder(it)
|
||||
},
|
||||
onMediaClicked = { media, selected ->
|
||||
if (selected) {
|
||||
callbacks.onMediaUnselected(media)
|
||||
} else {
|
||||
callbacks.onMediaSelected(media)
|
||||
}
|
||||
},
|
||||
callbacks.isMultiselectEnabled()
|
||||
)
|
||||
|
||||
galleryRecycler.adapter = galleryAdapter
|
||||
galleryRecycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(2)))
|
||||
|
||||
viewStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
bottomBarGroup.visible = state.selectedMedia.isNotEmpty()
|
||||
countButton.setCount(state.selectedMedia.size)
|
||||
selectedAdapter.submitList(state.selectedMedia.map { MediaGallerySelectedItem.Model(it) }) {
|
||||
if (state.selectedMedia.isNotEmpty()) {
|
||||
selectedRecycler.smoothScrollToPosition(state.selectedMedia.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
toolbar.title = state.bucketTitle
|
||||
}
|
||||
|
||||
val galleryItemsWithSelection = LiveDataUtil.combineLatest(
|
||||
Transformations.map(viewModel.state) { it.items },
|
||||
Transformations.map(viewStateLiveData) { it.selectedMedia }
|
||||
) { galleryItems, selectedMedia ->
|
||||
galleryItems.map {
|
||||
if (it is MediaGallerySelectableItem.FileModel) {
|
||||
it.copy(isSelected = selectedMedia.contains(it.media))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
galleryItemsWithSelection.observe(viewLifecycleOwner) {
|
||||
galleryAdapter.submitList(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun onViewStateUpdated(state: ViewState) {
|
||||
viewStateLiveData.value = state
|
||||
}
|
||||
|
||||
data class ViewState(
|
||||
val selectedMedia: List<Media> = listOf()
|
||||
)
|
||||
|
||||
interface Callbacks {
|
||||
fun isMultiselectEnabled(): Boolean = false
|
||||
fun onMediaSelected(media: Media)
|
||||
fun onMediaUnselected(media: Media): Unit = throw UnsupportedOperationException()
|
||||
fun onSelectedMediaClicked(media: Media): Unit = throw UnsupportedOperationException()
|
||||
fun onNavigateToCamera(): Unit = throw UnsupportedOperationException()
|
||||
fun onSubmit(): Unit = throw UnsupportedOperationException()
|
||||
fun onToolbarNavigationClicked(): Unit = throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.gallery
|
||||
|
||||
import android.content.Context
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaFolder
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository
|
||||
|
||||
class MediaGalleryRepository(context: Context, private val mediaRepository: MediaRepository) {
|
||||
private val context: Context = context.applicationContext
|
||||
|
||||
fun getFolders(onFoldersRetrieved: (List<MediaFolder>) -> Unit) {
|
||||
mediaRepository.getFolders(context) { onFoldersRetrieved(it) }
|
||||
}
|
||||
|
||||
fun getMedia(bucketId: String, onMediaRetrieved: (List<Media>) -> Unit) {
|
||||
mediaRepository.getMediaInBucket(context, bucketId) { onMediaRetrieved(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.gallery
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaFolder
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
typealias OnMediaFolderClicked = (MediaFolder) -> Unit
|
||||
typealias OnMediaClicked = (Media, Boolean) -> Unit
|
||||
|
||||
object MediaGallerySelectableItem {
|
||||
|
||||
fun registerAdapter(
|
||||
mappingAdapter: MappingAdapter,
|
||||
onMediaFolderClicked: OnMediaFolderClicked,
|
||||
onMediaClicked: OnMediaClicked,
|
||||
isMultiselectEnabled: Boolean
|
||||
) {
|
||||
mappingAdapter.registerFactory(FolderModel::class.java, MappingAdapter.LayoutFactory({ FolderViewHolder(it, onMediaFolderClicked) }, R.layout.v2_media_gallery_folder_item))
|
||||
mappingAdapter.registerFactory(FileModel::class.java, MappingAdapter.LayoutFactory({ FileViewHolder(it, onMediaClicked) }, if (isMultiselectEnabled) R.layout.v2_media_gallery_item else R.layout.v2_media_gallery_item_no_check))
|
||||
}
|
||||
|
||||
class FolderModel(val mediaFolder: MediaFolder) : MappingModel<FolderModel> {
|
||||
override fun areItemsTheSame(newItem: FolderModel): Boolean {
|
||||
return mediaFolder.bucketId == newItem.mediaFolder.bucketId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: FolderModel): Boolean {
|
||||
return mediaFolder.bucketId == newItem.mediaFolder.bucketId &&
|
||||
mediaFolder.thumbnailUri == newItem.mediaFolder.thumbnailUri
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseViewHolder<T : MappingModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
protected val imageView: ImageView = itemView.findViewById(R.id.media_gallery_image)
|
||||
protected val playOverlay: ImageView = itemView.findViewById(R.id.media_gallery_play_overlay)
|
||||
protected val checkView: ImageView? = itemView.findViewById(R.id.media_gallery_check)
|
||||
protected val title: TextView? = itemView.findViewById(R.id.media_gallery_title)
|
||||
|
||||
init {
|
||||
(itemView as AspectRatioFrameLayout).setAspectRatio(1f)
|
||||
}
|
||||
}
|
||||
|
||||
class FolderViewHolder(itemView: View, private val onMediaFolderClicked: OnMediaFolderClicked) : BaseViewHolder<FolderModel>(itemView) {
|
||||
override fun bind(model: FolderModel) {
|
||||
GlideApp.with(imageView)
|
||||
.load(DecryptableStreamUriLoader.DecryptableUri(model.mediaFolder.thumbnailUri))
|
||||
.into(imageView)
|
||||
|
||||
playOverlay.visible = false
|
||||
itemView.setOnClickListener { onMediaFolderClicked(model.mediaFolder) }
|
||||
title?.text = model.mediaFolder.title
|
||||
title?.visible = true
|
||||
}
|
||||
}
|
||||
|
||||
data class FileModel(val media: Media, val isSelected: Boolean) : MappingModel<FileModel> {
|
||||
override fun areItemsTheSame(newItem: FileModel): Boolean {
|
||||
return newItem.media == media
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: FileModel): Boolean {
|
||||
return newItem.media == media && isSelected == newItem.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
class FileViewHolder(itemView: View, private val onMediaClicked: OnMediaClicked) : BaseViewHolder<FileModel>(itemView) {
|
||||
override fun bind(model: FileModel) {
|
||||
GlideApp.with(imageView)
|
||||
.load(DecryptableStreamUriLoader.DecryptableUri(model.media.uri))
|
||||
.into(imageView)
|
||||
|
||||
checkView?.isSelected = model.isSelected
|
||||
playOverlay.visible = MediaUtil.isVideo(model.media.mimeType) && !model.media.isVideoGif
|
||||
itemView.setOnClickListener { onMediaClicked(model.media, model.isSelected) }
|
||||
title?.visible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.gallery
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import com.bumptech.glide.Glide
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
typealias OnSelectedMediaClicked = (Media) -> Unit
|
||||
|
||||
object MediaGallerySelectedItem {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter, onSelectedMediaClicked: OnSelectedMediaClicked) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onSelectedMediaClicked) }, R.layout.v2_media_selection_item))
|
||||
}
|
||||
|
||||
class Model(val media: Media) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return media.uri == newItem.media.uri
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return media.uri == newItem.media.uri
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View, private val onSelectedMediaClicked: OnSelectedMediaClicked) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val imageView: ImageView = itemView.findViewById(R.id.media_selection_image)
|
||||
private val videoOverlay: ImageView = itemView.findViewById(R.id.media_selection_play_overlay)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
Glide.with(imageView)
|
||||
.load(DecryptableStreamUriLoader.DecryptableUri(model.media.uri))
|
||||
.centerCrop()
|
||||
.into(imageView)
|
||||
|
||||
videoOverlay.visible = MediaUtil.isVideo(model.media.mimeType) && !model.media.isVideoGif
|
||||
itemView.setOnClickListener { onSelectedMediaClicked(model.media) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.gallery
|
||||
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
|
||||
data class MediaGalleryState(
|
||||
val bucketId: String?,
|
||||
val bucketTitle: String?,
|
||||
val items: List<MappingModel<*>> = listOf()
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.gallery
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.mediasend.MediaFolder
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class MediaGalleryViewModel(bucketId: String?, bucketTitle: String?, private val repository: MediaGalleryRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(MediaGalleryState(bucketId, bucketTitle))
|
||||
|
||||
val state: LiveData<MediaGalleryState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
loadItemsForBucket(bucketId, bucketTitle)
|
||||
}
|
||||
|
||||
fun pop(): Boolean {
|
||||
return if (store.state.bucketId == null) {
|
||||
true
|
||||
} else {
|
||||
loadItemsForBucket(null, null)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun setMediaFolder(mediaFolder: MediaFolder) {
|
||||
loadItemsForBucket(mediaFolder.bucketId, mediaFolder.title)
|
||||
}
|
||||
|
||||
private fun loadItemsForBucket(bucketId: String?, bucketTitle: String?) {
|
||||
if (bucketId == null) {
|
||||
repository.getFolders { folders ->
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
bucketId = bucketId, bucketTitle = bucketTitle,
|
||||
items = folders.map {
|
||||
MediaGallerySelectableItem.FolderModel(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
repository.getMedia(bucketId) { media ->
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
bucketId = bucketId, bucketTitle = bucketTitle,
|
||||
items = media.map {
|
||||
MediaGallerySelectableItem.FileModel(it, false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val bucketId: String?,
|
||||
private val bucketTitle: String?,
|
||||
private val repository: MediaGalleryRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(MediaGalleryViewModel(bucketId, bucketTitle, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.gallery
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForCamera
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
|
||||
private const val MEDIA_GALLERY_TAG = "MEDIA_GALLERY"
|
||||
|
||||
class MediaSelectionGalleryFragment : Fragment(R.layout.fragment_container), MediaGalleryFragment.Callbacks {
|
||||
|
||||
private lateinit var mediaGalleryFragment: MediaGalleryFragment
|
||||
|
||||
private val navigator = MediaSelectionNavigator(
|
||||
toCamera = R.id.action_mediaGalleryFragment_to_mediaCaptureFragment
|
||||
)
|
||||
|
||||
private val sharedViewModel: MediaSelectionViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
mediaGalleryFragment = ensureMediaGalleryFragment()
|
||||
|
||||
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
mediaGalleryFragment.onViewStateUpdated(MediaGalleryFragment.ViewState(state.selectedMedia))
|
||||
}
|
||||
|
||||
if (arguments?.containsKey("first") == true) {
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureMediaGalleryFragment(): MediaGalleryFragment {
|
||||
val fragmentInManager: MediaGalleryFragment? = childFragmentManager.findFragmentByTag(MEDIA_GALLERY_TAG) as? MediaGalleryFragment
|
||||
|
||||
return if (fragmentInManager != null) {
|
||||
fragmentInManager
|
||||
} else {
|
||||
val mediaGalleryFragment = MediaGalleryFragment()
|
||||
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(
|
||||
R.id.fragment_container,
|
||||
mediaGalleryFragment,
|
||||
MEDIA_GALLERY_TAG
|
||||
)
|
||||
.commitNowAllowingStateLoss()
|
||||
|
||||
mediaGalleryFragment
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
override fun isMultiselectEnabled(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMediaSelected(media: Media) {
|
||||
sharedViewModel.addMedia(media)
|
||||
}
|
||||
|
||||
override fun onMediaUnselected(media: Media) {
|
||||
sharedViewModel.removeMedia(media)
|
||||
}
|
||||
|
||||
override fun onSelectedMediaClicked(media: Media) {
|
||||
sharedViewModel.setFocusedMedia(media)
|
||||
navigator.goToReview(requireView())
|
||||
}
|
||||
|
||||
override fun onNavigateToCamera() {
|
||||
requestPermissionsForCamera {
|
||||
navigator.goToCamera(requireView())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSubmit() {
|
||||
navigator.goToReview(requireView())
|
||||
}
|
||||
|
||||
override fun onToolbarNavigationClicked() {
|
||||
Navigation.findNavController(requireView()).popBackStack()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.images
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorHudV2
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val IMAGE_EDITOR_TAG = "image.editor.fragment"
|
||||
|
||||
private val MODE_DELAY = TimeUnit.MILLISECONDS.toMillis(300)
|
||||
|
||||
/**
|
||||
* Displays the chosen image within the image editor. Also manages the "touch enabled" state of the shared
|
||||
* view model. We utilize delays here to help with Animation choreography.
|
||||
*/
|
||||
class MediaReviewImagePageFragment : Fragment(R.layout.fragment_container), ImageEditorFragment.Controller {
|
||||
|
||||
private lateinit var imageEditorFragment: ImageEditorFragment
|
||||
|
||||
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private lateinit var hudCommandDisposable: Disposable
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
imageEditorFragment = ensureImageEditorFragment()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
hudCommandDisposable.dispose()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
hudCommandDisposable = sharedViewModel.hudCommands.subscribe { command ->
|
||||
if (isResumed) {
|
||||
when (command) {
|
||||
HudCommand.StartDraw -> {
|
||||
sharedViewModel.setTouchEnabled(false)
|
||||
requireView().postDelayed(
|
||||
{
|
||||
imageEditorFragment.setMode(ImageEditorHudV2.Mode.DRAW)
|
||||
},
|
||||
MODE_DELAY
|
||||
)
|
||||
}
|
||||
HudCommand.StartCropAndRotate -> {
|
||||
sharedViewModel.setTouchEnabled(false)
|
||||
requireView().postDelayed(
|
||||
{
|
||||
imageEditorFragment.setMode(ImageEditorHudV2.Mode.CROP)
|
||||
},
|
||||
MODE_DELAY
|
||||
)
|
||||
}
|
||||
HudCommand.SaveMedia -> imageEditorFragment.onSave()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
restoreImageEditorState()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
sharedViewModel.setEditorState(requireUri(), requireNotNull(imageEditorFragment.saveState()))
|
||||
}
|
||||
|
||||
private fun ensureImageEditorFragment(): ImageEditorFragment {
|
||||
val fragmentInManager: ImageEditorFragment? = childFragmentManager.findFragmentByTag(IMAGE_EDITOR_TAG) as? ImageEditorFragment
|
||||
|
||||
return if (fragmentInManager != null) {
|
||||
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
|
||||
fragmentInManager
|
||||
} else {
|
||||
val imageEditorFragment = ImageEditorFragment.newInstance(
|
||||
requireUri()
|
||||
)
|
||||
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(
|
||||
R.id.fragment_container,
|
||||
imageEditorFragment,
|
||||
IMAGE_EDITOR_TAG
|
||||
)
|
||||
.commitAllowingStateLoss()
|
||||
|
||||
imageEditorFragment
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelable(ARG_URI))
|
||||
|
||||
override fun onTouchEventsNeeded(needed: Boolean) {
|
||||
if (isResumed) {
|
||||
if (!needed) {
|
||||
requireView().postDelayed(
|
||||
{
|
||||
sharedViewModel.setTouchEnabled(!needed)
|
||||
},
|
||||
MODE_DELAY
|
||||
)
|
||||
} else {
|
||||
sharedViewModel.setTouchEnabled(!needed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) = Unit
|
||||
|
||||
override fun onDoneEditing() {
|
||||
imageEditorFragment.setMode(ImageEditorHudV2.Mode.NONE)
|
||||
|
||||
if (isResumed) {
|
||||
sharedViewModel.setEditorState(requireUri(), requireNotNull(imageEditorFragment.saveState()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelEditing() {
|
||||
restoreImageEditorState()
|
||||
}
|
||||
|
||||
override fun onMainImageLoaded() {
|
||||
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
|
||||
}
|
||||
|
||||
override fun onMainImageFailedToLoad() {
|
||||
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
|
||||
}
|
||||
|
||||
private fun restoreImageEditorState() {
|
||||
val data = sharedViewModel.getEditorState(requireUri()) as? ImageEditorFragment.Data
|
||||
|
||||
if (data != null) {
|
||||
imageEditorFragment.restoreState(data)
|
||||
} else {
|
||||
imageEditorFragment.onClearAll()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_URI = "arg.uri"
|
||||
|
||||
fun newInstance(uri: Uri): Fragment {
|
||||
return MediaReviewImagePageFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_URI, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.review
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ComposeText
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout
|
||||
import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiToggle
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.views.Stub
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_add_message_dialog_fragment) {
|
||||
|
||||
private val viewModel: MediaSelectionViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
private val mentionsViewModel: MentionsPickerViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() },
|
||||
factoryProducer = { MentionsPickerViewModel.Factory() }
|
||||
)
|
||||
|
||||
private lateinit var input: ComposeText
|
||||
private lateinit var emojiDrawerToggle: EmojiToggle
|
||||
private lateinit var emojiDrawerStub: Stub<MediaKeyboard>
|
||||
private lateinit var hud: InputAwareLayout
|
||||
private lateinit var mentionsContainer: ViewGroup
|
||||
|
||||
private var requestedEmojiDrawer: Boolean = false
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val themeWrapper = ContextThemeWrapper(inflater.context, R.style.TextSecure_DarkTheme)
|
||||
val themedInflater = LayoutInflater.from(themeWrapper)
|
||||
|
||||
return super.onCreateView(themedInflater, container, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
input = view.findViewById(R.id.add_a_message_input)
|
||||
input.setText(requireArguments().getCharSequence(ARG_INITIAL_TEXT))
|
||||
|
||||
emojiDrawerToggle = view.findViewById(R.id.emoji_toggle)
|
||||
emojiDrawerStub = Stub(view.findViewById(R.id.emoji_drawer_stub))
|
||||
if (SignalStore.settings().isPreferSystemEmoji) {
|
||||
emojiDrawerToggle.visible = false
|
||||
} else {
|
||||
emojiDrawerToggle.setOnClickListener { onEmojiToggleClicked() }
|
||||
}
|
||||
|
||||
hud = view.findViewById(R.id.hud)
|
||||
hud.setOnClickListener { dismissAllowingStateLoss() }
|
||||
|
||||
val confirm: View = view.findViewById(R.id.confirm_button)
|
||||
confirm.setOnClickListener {
|
||||
viewModel.setMessage(input.text)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
viewModel.hudCommands.observeOn(AndroidSchedulers.mainThread()).subscribe {
|
||||
when (it) {
|
||||
HudCommand.OpenEmojiSearch -> openEmojiSearch()
|
||||
HudCommand.CloseEmojiSearch -> closeEmojiSearch()
|
||||
is HudCommand.EmojiKeyEvent -> onKeyEvent(it.keyEvent)
|
||||
is HudCommand.EmojiInsert -> onEmojiSelected(it.emoji)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initializeMentions()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
requestedEmojiDrawer = false
|
||||
ViewUtil.focusAndShowKeyboard(input)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
ViewUtil.hideKeyboard(requireContext(), input)
|
||||
}
|
||||
|
||||
override fun onKeyboardHidden() {
|
||||
if (!requestedEmojiDrawer) {
|
||||
super.onKeyboardHidden()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
disposables.dispose()
|
||||
|
||||
input.setMentionQueryChangedListener(null)
|
||||
input.setMentionValidator(null)
|
||||
}
|
||||
|
||||
private fun initializeMentions() {
|
||||
val recipientId: RecipientId = viewModel.destination.getRecipientId() ?: return
|
||||
|
||||
mentionsContainer = requireView().findViewById(R.id.mentions_picker_container)
|
||||
|
||||
Recipient.live(recipientId).observe(viewLifecycleOwner) { recipient ->
|
||||
mentionsViewModel.onRecipientChange(recipient)
|
||||
|
||||
input.setMentionQueryChangedListener { query ->
|
||||
if (recipient.isPushV2Group) {
|
||||
ensureMentionsContainerFilled()
|
||||
mentionsViewModel.onQueryChange(query)
|
||||
}
|
||||
}
|
||||
|
||||
input.setMentionValidator { annotations ->
|
||||
if (!recipient.isPushV2Group) {
|
||||
annotations
|
||||
} else {
|
||||
|
||||
val validRecipientIds: Set<String> = recipient.participants
|
||||
.map { r -> MentionAnnotation.idToMentionAnnotationValue(r.id) }
|
||||
.toSet()
|
||||
|
||||
annotations
|
||||
.filter { !validRecipientIds.contains(it.value) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mentionsViewModel.selectedRecipient.observe(viewLifecycleOwner) { recipient ->
|
||||
input.replaceTextWithMention(recipient.getDisplayName(requireContext()), recipient.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureMentionsContainerFilled() {
|
||||
val mentionsFragment = childFragmentManager.findFragmentById(R.id.mentions_picker_container)
|
||||
if (mentionsFragment == null) {
|
||||
childFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.mentions_picker_container, MentionsPickerFragment())
|
||||
.commitNowAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEmojiToggleClicked() {
|
||||
if (!emojiDrawerStub.resolved()) {
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
|
||||
emojiDrawerStub.get().setFragmentManager(childFragmentManager)
|
||||
emojiDrawerToggle.attach(emojiDrawerStub.get())
|
||||
}
|
||||
|
||||
if (hud.currentInput == emojiDrawerStub.get()) {
|
||||
hud.showSoftkey(input)
|
||||
} else {
|
||||
requestedEmojiDrawer = true
|
||||
hud.hideSoftkey(input) {
|
||||
hud.post {
|
||||
hud.show(input, emojiDrawerStub.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openEmojiSearch() {
|
||||
if (emojiDrawerStub.resolved()) {
|
||||
emojiDrawerStub.get().onOpenEmojiSearch()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeEmojiSearch() {
|
||||
if (emojiDrawerStub.resolved()) {
|
||||
emojiDrawerStub.get().onCloseEmojiSearch()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEmojiSelected(emoji: String?) {
|
||||
input.insertEmoji(emoji)
|
||||
}
|
||||
|
||||
private fun onKeyEvent(keyEvent: KeyEvent?) {
|
||||
input.dispatchKeyEvent(keyEvent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG = "ADD_MESSAGE_DIALOG_FRAGMENT"
|
||||
|
||||
private const val ARG_INITIAL_TEXT = "arg.initial.text"
|
||||
|
||||
fun show(fragmentManager: FragmentManager, initialText: CharSequence?) {
|
||||
AddMessageDialogFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putCharSequence(ARG_INITIAL_TEXT, initialText)
|
||||
}
|
||||
}.show(fragmentManager, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.review
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
typealias OnAddMediaItemClicked = () -> Unit
|
||||
|
||||
object MediaReviewAddItem {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter, onAddMediaItemClicked: OnAddMediaItemClicked) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAddMediaItemClicked) }, R.layout.v2_media_review_add_media_item))
|
||||
}
|
||||
|
||||
object Model : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View, onAddMediaItemClicked: OnAddMediaItemClicked) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener { onAddMediaItemClicked() }
|
||||
}
|
||||
|
||||
override fun bind(model: Model) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.review
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ObjectAnimator
|
||||
import android.view.View
|
||||
import androidx.core.animation.doOnEnd
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
object MediaReviewAnimatorController {
|
||||
|
||||
fun getSlideInAnimator(view: View): Animator {
|
||||
return ObjectAnimator.ofFloat(view, "translationY", view.translationY, 0f)
|
||||
}
|
||||
|
||||
fun getSlideOutAnimator(view: View): Animator {
|
||||
return ObjectAnimator.ofFloat(view, "translationY", view.translationX, ViewUtil.dpToPx(48).toFloat())
|
||||
}
|
||||
|
||||
fun getFadeInAnimator(view: View): Animator {
|
||||
view.visible = true
|
||||
view.isEnabled = true
|
||||
|
||||
return ObjectAnimator.ofFloat(view, "alpha", view.alpha, 1f)
|
||||
}
|
||||
|
||||
fun getFadeOutAnimator(view: View): Animator {
|
||||
view.isEnabled = false
|
||||
|
||||
val animator = ObjectAnimator.ofFloat(view, "alpha", view.alpha, 0f)
|
||||
|
||||
animator.doOnEnd { view.visible = false }
|
||||
|
||||
return animator
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.review
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorSet
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.ViewSwitcher
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
|
||||
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForGallery
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
|
||||
/**
|
||||
* Allows the user to view and edit selected media.
|
||||
*/
|
||||
class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
|
||||
|
||||
private val sharedViewModel: MediaSelectionViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
private lateinit var callback: Callback
|
||||
|
||||
private lateinit var drawToolButton: View
|
||||
private lateinit var cropAndRotateButton: View
|
||||
private lateinit var qualityButton: ImageView
|
||||
private lateinit var saveButton: View
|
||||
private lateinit var sendButton: View
|
||||
private lateinit var addMediaButton: View
|
||||
private lateinit var viewOnceButton: ViewSwitcher
|
||||
private lateinit var viewOnceMessage: TextView
|
||||
private lateinit var addMessageButton: TextView
|
||||
private lateinit var addMessageEntry: TextView
|
||||
private lateinit var recipientDisplay: TextView
|
||||
private lateinit var pager: ViewPager2
|
||||
private lateinit var controls: ConstraintLayout
|
||||
private lateinit var selectionRecycler: RecyclerView
|
||||
private lateinit var controlsShade: View
|
||||
|
||||
private val navigator = MediaSelectionNavigator(
|
||||
toGallery = R.id.action_mediaReviewFragment_to_mediaGalleryFragment,
|
||||
)
|
||||
|
||||
private var animatorSet: AnimatorSet? = null
|
||||
private var disposables: CompositeDisposable? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
postponeEnterTransition()
|
||||
|
||||
callback = requireNotNull(findListener())
|
||||
|
||||
drawToolButton = view.findViewById(R.id.draw_tool)
|
||||
cropAndRotateButton = view.findViewById(R.id.crop_and_rotate_tool)
|
||||
qualityButton = view.findViewById(R.id.quality_selector)
|
||||
saveButton = view.findViewById(R.id.save_to_media)
|
||||
sendButton = view.findViewById(R.id.send)
|
||||
addMediaButton = view.findViewById(R.id.add_media)
|
||||
viewOnceButton = view.findViewById(R.id.view_once_toggle)
|
||||
addMessageButton = view.findViewById(R.id.add_a_message)
|
||||
addMessageEntry = view.findViewById(R.id.add_a_message_entry)
|
||||
recipientDisplay = view.findViewById(R.id.recipient)
|
||||
pager = view.findViewById(R.id.media_pager)
|
||||
controls = view.findViewById(R.id.controls)
|
||||
selectionRecycler = view.findViewById(R.id.selection_recycler)
|
||||
controlsShade = view.findViewById(R.id.controls_shade)
|
||||
viewOnceMessage = view.findViewById(R.id.view_once_message)
|
||||
|
||||
val pagerAdapter = MediaReviewFragmentPagerAdapter(this)
|
||||
|
||||
disposables = CompositeDisposable()
|
||||
disposables?.add(
|
||||
sharedViewModel.hudCommands.subscribe {
|
||||
when (it) {
|
||||
HudCommand.ResumeEntryTransition -> startPostponedEnterTransition()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
pager.adapter = pagerAdapter
|
||||
|
||||
drawToolButton.setOnClickListener {
|
||||
sharedViewModel.sendCommand(HudCommand.StartDraw)
|
||||
}
|
||||
|
||||
cropAndRotateButton.setOnClickListener {
|
||||
sharedViewModel.sendCommand(HudCommand.StartCropAndRotate)
|
||||
}
|
||||
|
||||
qualityButton.setOnClickListener {
|
||||
QualitySelectorBottomSheetDialog.show(parentFragmentManager)
|
||||
}
|
||||
|
||||
saveButton.setOnClickListener {
|
||||
sharedViewModel.sendCommand(HudCommand.SaveMedia)
|
||||
}
|
||||
|
||||
setFragmentResultListener(MultiselectForwardFragment.RESULT_SELECTION) { _, bundle ->
|
||||
val recipientIds: List<RecipientId> = requireNotNull(bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION_RECIPIENTS))
|
||||
performSend(recipientIds)
|
||||
}
|
||||
|
||||
sendButton.setOnClickListener {
|
||||
if (sharedViewModel.isContactSelectionRequired) {
|
||||
val args = MultiselectForwardFragmentArgs(false, title = R.string.MediaReviewFragment__send_to)
|
||||
MultiselectForwardFragment.show(parentFragmentManager, args)
|
||||
} else {
|
||||
performSend()
|
||||
}
|
||||
}
|
||||
|
||||
addMediaButton.setOnClickListener {
|
||||
launchGallery()
|
||||
}
|
||||
|
||||
viewOnceButton.setOnClickListener {
|
||||
sharedViewModel.incrementViewOnceState()
|
||||
}
|
||||
|
||||
addMessageButton.setOnClickListener {
|
||||
AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message)
|
||||
}
|
||||
|
||||
addMessageEntry.setOnClickListener {
|
||||
AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message)
|
||||
}
|
||||
|
||||
if (sharedViewModel.isReply) {
|
||||
addMessageButton.setText(R.string.MediaReviewFragment__add_a_reply)
|
||||
}
|
||||
|
||||
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
sharedViewModel.setFocusedMedia(position)
|
||||
}
|
||||
})
|
||||
|
||||
val selectionAdapter = MappingAdapter()
|
||||
MediaReviewAddItem.register(selectionAdapter) {
|
||||
launchGallery()
|
||||
}
|
||||
MediaReviewSelectedItem.register(selectionAdapter) { media, isSelected ->
|
||||
if (isSelected) {
|
||||
sharedViewModel.removeMedia(media)
|
||||
} else {
|
||||
sharedViewModel.setFocusedMedia(media)
|
||||
}
|
||||
}
|
||||
selectionRecycler.adapter = selectionAdapter
|
||||
|
||||
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
pagerAdapter.submitMedia(state.selectedMedia)
|
||||
|
||||
selectionAdapter.submitList(
|
||||
state.selectedMedia.map { MediaReviewSelectedItem.Model(it, state.focusedMedia == it) } + MediaReviewAddItem.Model
|
||||
)
|
||||
|
||||
presentPager(state)
|
||||
presentAddMessageEntry(state.message)
|
||||
presentImageQualityToggle(state.quality)
|
||||
|
||||
viewOnceButton.displayedChild = if (state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE) 1 else 0
|
||||
sendButton.isEnabled = !state.isSent && state.selectedMedia.isNotEmpty()
|
||||
|
||||
computeViewStateAndAnimate(state)
|
||||
}
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
callback.onPopFromReview()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
sharedViewModel.kick()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
disposables?.dispose()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun launchGallery() {
|
||||
requestPermissionsForGallery {
|
||||
navigator.goToGallery(requireView())
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSend(selection: List<RecipientId> = listOf()) {
|
||||
sharedViewModel
|
||||
.send(selection)
|
||||
.subscribe(
|
||||
{ result -> callback.onSentWithResult(result) },
|
||||
{ error -> callback.onSendError(error) },
|
||||
{ callback.onSentWithoutResult() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentAddMessageEntry(message: CharSequence?) {
|
||||
addMessageEntry.text = message
|
||||
}
|
||||
|
||||
private fun presentImageQualityToggle(quality: SentMediaQuality) {
|
||||
qualityButton.setImageResource(
|
||||
when (quality) {
|
||||
SentMediaQuality.STANDARD -> R.drawable.ic_sq_36
|
||||
SentMediaQuality.HIGH -> R.drawable.ic_hq_36
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentPager(state: MediaSelectionState) {
|
||||
pager.isUserInputEnabled = state.isTouchEnabled
|
||||
|
||||
val indexOfSelectedItem = state.selectedMedia.indexOf(state.focusedMedia)
|
||||
|
||||
if (pager.currentItem == indexOfSelectedItem) {
|
||||
return
|
||||
}
|
||||
|
||||
if (indexOfSelectedItem != -1) {
|
||||
pager.setCurrentItem(indexOfSelectedItem, false)
|
||||
} else {
|
||||
pager.setCurrentItem(0, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeViewStateAndAnimate(state: MediaSelectionState) {
|
||||
this.animatorSet?.cancel()
|
||||
|
||||
val animators = mutableListOf<Animator>()
|
||||
|
||||
animators.addAll(computeAddMessageAnimators(state))
|
||||
animators.addAll(computeViewOnceButtonAnimators(state))
|
||||
animators.addAll(computeAddMediaButtonsAnimators(state))
|
||||
animators.addAll(computeSendAndSaveButtonAnimators(state))
|
||||
animators.addAll(computeQualityButtonAnimators(state))
|
||||
animators.addAll(computeCropAndRotateButtonAnimators(state))
|
||||
animators.addAll(computeDrawToolButtonAnimators(state))
|
||||
animators.addAll(computeRecipientDisplayAnimators(state))
|
||||
animators.addAll(computeControlsShadeAnimators(state))
|
||||
|
||||
val animatorSet = AnimatorSet()
|
||||
animatorSet.playTogether(animators)
|
||||
animatorSet.start()
|
||||
|
||||
this.animatorSet = animatorSet
|
||||
}
|
||||
|
||||
private fun computeControlsShadeAnimators(state: MediaSelectionState): List<Animator> {
|
||||
return if (state.isTouchEnabled) {
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(controlsShade))
|
||||
} else {
|
||||
listOf(MediaReviewAnimatorController.getFadeOutAnimator(controlsShade))
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeAddMessageAnimators(state: MediaSelectionState): List<Animator> {
|
||||
return when {
|
||||
!state.isTouchEnabled -> {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry)
|
||||
)
|
||||
}
|
||||
state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE -> {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeInAnimator(viewOnceMessage),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry)
|
||||
)
|
||||
}
|
||||
state.message.isNullOrEmpty() -> {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage),
|
||||
MediaReviewAnimatorController.getFadeInAnimator(addMessageButton),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMessageEntry)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(viewOnceMessage),
|
||||
MediaReviewAnimatorController.getFadeInAnimator(addMessageEntry),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMessageButton)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeViewOnceButtonAnimators(state: MediaSelectionState): List<Animator> {
|
||||
return if (state.isTouchEnabled && state.selectedMedia.size == 1) {
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(viewOnceButton))
|
||||
} else {
|
||||
listOf(MediaReviewAnimatorController.getFadeOutAnimator(viewOnceButton))
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeAddMediaButtonsAnimators(state: MediaSelectionState): List<Animator> {
|
||||
return when {
|
||||
!state.isTouchEnabled || state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE -> {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMediaButton),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(selectionRecycler)
|
||||
)
|
||||
}
|
||||
state.selectedMedia.size > 1 -> {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(addMediaButton),
|
||||
MediaReviewAnimatorController.getFadeInAnimator(selectionRecycler)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeInAnimator(addMediaButton),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(selectionRecycler)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeSendAndSaveButtonAnimators(state: MediaSelectionState): List<Animator> {
|
||||
|
||||
val slideIn = listOf(
|
||||
MediaReviewAnimatorController.getSlideInAnimator(sendButton),
|
||||
MediaReviewAnimatorController.getSlideInAnimator(saveButton)
|
||||
)
|
||||
|
||||
return slideIn + if (state.isTouchEnabled) {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeInAnimator(sendButton),
|
||||
MediaReviewAnimatorController.getFadeInAnimator(saveButton)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(sendButton),
|
||||
MediaReviewAnimatorController.getFadeOutAnimator(saveButton)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeQualityButtonAnimators(state: MediaSelectionState): List<Animator> {
|
||||
val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(qualityButton))
|
||||
|
||||
return slide + if (state.isTouchEnabled && state.selectedMedia.any { MediaUtil.isImageType(it.mimeType) }) {
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(qualityButton))
|
||||
} else {
|
||||
listOf(MediaReviewAnimatorController.getFadeOutAnimator(qualityButton))
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeCropAndRotateButtonAnimators(state: MediaSelectionState): List<Animator> {
|
||||
val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(cropAndRotateButton))
|
||||
|
||||
return slide + if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) {
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(cropAndRotateButton))
|
||||
} else {
|
||||
listOf(MediaReviewAnimatorController.getFadeOutAnimator(cropAndRotateButton))
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeDrawToolButtonAnimators(state: MediaSelectionState): List<Animator> {
|
||||
val slide = listOf(MediaReviewAnimatorController.getSlideInAnimator(drawToolButton))
|
||||
|
||||
return slide + if (state.isTouchEnabled && MediaUtil.isImageAndNotGif(state.focusedMedia?.mimeType ?: "")) {
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(drawToolButton))
|
||||
} else {
|
||||
listOf(MediaReviewAnimatorController.getFadeOutAnimator(drawToolButton))
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeRecipientDisplayAnimators(state: MediaSelectionState): List<Animator> {
|
||||
return if (state.isTouchEnabled && state.recipient != null) {
|
||||
recipientDisplay.text = state.recipient.getDisplayName(requireContext())
|
||||
listOf(MediaReviewAnimatorController.getFadeInAnimator(recipientDisplay))
|
||||
} else {
|
||||
listOf(MediaReviewAnimatorController.getFadeOutAnimator(recipientDisplay))
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult)
|
||||
fun onSentWithoutResult()
|
||||
fun onSendError(error: Throwable)
|
||||
fun onPopFromReview()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.review
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendGifFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.images.MediaReviewImagePageFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.videos.MediaReviewVideoPageFragment
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import java.util.LinkedList
|
||||
|
||||
class MediaReviewFragmentPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
|
||||
|
||||
private val mediaList: MutableList<Media> = mutableListOf()
|
||||
|
||||
fun submitMedia(media: List<Media>) {
|
||||
val oldMedia: List<Media> = LinkedList(mediaList)
|
||||
mediaList.clear()
|
||||
mediaList.addAll(media)
|
||||
|
||||
DiffUtil
|
||||
.calculateDiff(Callback(oldMedia, mediaList))
|
||||
.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
if (position > mediaList.size || position < 0) {
|
||||
return RecyclerView.NO_ID
|
||||
}
|
||||
|
||||
return mediaList[position].uri.hashCode().toLong()
|
||||
}
|
||||
|
||||
override fun containsItem(itemId: Long): Boolean {
|
||||
return mediaList.any { it.uri.hashCode().toLong() == itemId }
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = mediaList.size
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
val mediaItem: Media = mediaList[position]
|
||||
|
||||
return when {
|
||||
MediaUtil.isGif(mediaItem.mimeType) -> MediaSendGifFragment.newInstance(mediaItem.uri)
|
||||
MediaUtil.isImageType(mediaItem.mimeType) -> MediaReviewImagePageFragment.newInstance(mediaItem.uri)
|
||||
MediaUtil.isVideoType(mediaItem.mimeType) -> MediaReviewVideoPageFragment.newInstance(mediaItem.uri, mediaItem.isVideoGif)
|
||||
else -> {
|
||||
throw UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.mimeType + "'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Callback(
|
||||
private val oldList: List<Media>,
|
||||
private val newList: List<Media>
|
||||
) : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
|
||||
override fun getNewListSize(): Int = newList.size
|
||||
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return oldList[oldItemPosition].uri == newList[newItemPosition].uri
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return oldList[oldItemPosition] == newList[newItemPosition]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.review
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import com.bumptech.glide.Glide
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
typealias OnSelectedMediaClicked = (Media, Boolean) -> Unit
|
||||
|
||||
object MediaReviewSelectedItem {
|
||||
fun register(mappingAdapter: MappingAdapter, onSelectedMediaClicked: OnSelectedMediaClicked) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onSelectedMediaClicked) }, R.layout.v2_media_review_selected_item))
|
||||
}
|
||||
|
||||
class Model(val media: Media, val isSelected: Boolean) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return media == newItem.media
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return media == newItem.media && isSelected == newItem.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View, private val onSelectedMediaClicked: OnSelectedMediaClicked) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val imageView: ImageView = itemView.findViewById(R.id.media_review_selected_image)
|
||||
private val playOverlay: ImageView = itemView.findViewById(R.id.media_review_play_overlay)
|
||||
private val selectedOverlay: ImageView = itemView.findViewById(R.id.media_review_selected_overlay)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
Glide.with(imageView)
|
||||
.load(DecryptableStreamUriLoader.DecryptableUri(model.media.uri))
|
||||
.centerCrop()
|
||||
.into(imageView)
|
||||
|
||||
playOverlay.visible = MediaUtil.isNonGifVideo(model.media) && !model.isSelected
|
||||
selectedOverlay.isSelected = model.isSelected
|
||||
|
||||
itemView.contentDescription = if (model.isSelected) {
|
||||
context.getString(R.string.MediaReviewSelectedItem__tap_to_remove)
|
||||
} else {
|
||||
context.getString(R.string.MediaReviewSelectedItem__tap_to_select)
|
||||
}
|
||||
|
||||
itemView.setOnClickListener { onSelectedMediaClicked(model.media, model.isSelected) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
package org.thoughtcrime.securesms.mediasend.v2.review;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.ContextThemeWrapper;
|
||||
@@ -15,18 +15,20 @@ import androidx.lifecycle.ViewModelProviders;
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel;
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.views.CheckedLinearLayout;
|
||||
|
||||
/**
|
||||
* Dialog for selecting media quality, tightly coupled with {@link MediaSendViewModel}.
|
||||
* Dialog for selecting media quality, tightly coupled with {@link MediaSelectionViewModel}.
|
||||
*/
|
||||
public final class QualitySelectorBottomSheetDialog extends BottomSheetDialogFragment {
|
||||
|
||||
private MediaSendViewModel viewModel;
|
||||
private CheckedLinearLayout standard;
|
||||
private CheckedLinearLayout high;
|
||||
private MediaSelectionViewModel viewModel;
|
||||
private CheckedLinearLayout standard;
|
||||
private CheckedLinearLayout high;
|
||||
|
||||
public static void show(@NonNull FragmentManager manager) {
|
||||
QualitySelectorBottomSheetDialog fragment = new QualitySelectorBottomSheetDialog();
|
||||
@@ -60,17 +62,13 @@ public final class QualitySelectorBottomSheetDialog extends BottomSheetDialogFra
|
||||
|
||||
standard.setOnClickListener(listener);
|
||||
high.setOnClickListener(listener);
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(MediaSelectionViewModel.class);
|
||||
viewModel.getState().observe(getViewLifecycleOwner(), this::updateQuality);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(MediaSendViewModel.class);
|
||||
viewModel.getSentMediaQuality().observe(getViewLifecycleOwner(), this::updateQuality);
|
||||
}
|
||||
|
||||
private void updateQuality(@NonNull SentMediaQuality sentMediaQuality) {
|
||||
select(sentMediaQuality == SentMediaQuality.STANDARD ? standard : high);
|
||||
private void updateQuality(@NonNull MediaSelectionState selectionState) {
|
||||
select(selectionState.getQuality() == SentMediaQuality.STANDARD ? standard : high);
|
||||
}
|
||||
|
||||
private void select(@NonNull View view) {
|
||||
@@ -83,5 +81,4 @@ public final class QualitySelectorBottomSheetDialog extends BottomSheetDialogFra
|
||||
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
|
||||
BottomSheetUtil.show(manager, tag, this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.thoughtcrime.securesms.mediasend.v2.videos
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
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
|
||||
|
||||
private const val VIDEO_EDITOR_TAG = "video.editor.fragment"
|
||||
|
||||
/**
|
||||
* Page fragment which displays a single editable video (non-gif) to the user. Has an embedded MediaSendVideoFragment
|
||||
* and adds some extra support for saving and restoring state, as well as saving a video to disk.
|
||||
*/
|
||||
class MediaReviewVideoPageFragment : Fragment(R.layout.fragment_container), VideoEditorFragment.Controller {
|
||||
|
||||
private val sharedViewModel: MediaSelectionViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
private lateinit var videoEditorFragment: VideoEditorFragment
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
videoEditorFragment = ensureVideoEditorFragment()
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
restoreVideoEditorState()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
saveEditorState()
|
||||
}
|
||||
|
||||
private fun saveEditorState() {
|
||||
val saveState = videoEditorFragment.saveState()
|
||||
if (saveState != null) {
|
||||
sharedViewModel.setEditorState(requireUri(), saveState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerReady() {
|
||||
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
|
||||
}
|
||||
|
||||
override fun onPlayerError() {
|
||||
sharedViewModel.sendCommand(HudCommand.ResumeEntryTransition)
|
||||
}
|
||||
|
||||
override fun onTouchEventsNeeded(needed: Boolean) {
|
||||
sharedViewModel.setTouchEnabled(!needed)
|
||||
}
|
||||
|
||||
override fun onVideoBeginEdit(uri: Uri) {
|
||||
sharedViewModel.onVideoBeginEdit(uri)
|
||||
}
|
||||
|
||||
override fun onVideoEndEdit(uri: Uri) {
|
||||
saveEditorState()
|
||||
}
|
||||
|
||||
private fun restoreVideoEditorState() {
|
||||
val data = sharedViewModel.getEditorState(requireUri()) as? VideoEditorFragment.Data
|
||||
|
||||
if (data != null) {
|
||||
videoEditorFragment.restoreState(data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureVideoEditorFragment(): VideoEditorFragment {
|
||||
val fragmentInManager: VideoEditorFragment? = childFragmentManager.findFragmentByTag(VIDEO_EDITOR_TAG) as? VideoEditorFragment
|
||||
|
||||
return if (fragmentInManager != null) {
|
||||
fragmentInManager
|
||||
} else {
|
||||
val videoEditorFragment = VideoEditorFragment.newInstance(
|
||||
requireUri(),
|
||||
requireMaxCompressedVideoSize(),
|
||||
requireMaxAttachmentSize(),
|
||||
requireIsVideoGif()
|
||||
)
|
||||
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(
|
||||
R.id.fragment_container,
|
||||
videoEditorFragment,
|
||||
VIDEO_EDITOR_TAG
|
||||
)
|
||||
.commitAllowingStateLoss()
|
||||
|
||||
videoEditorFragment
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireUri(): Uri = requireNotNull(requireArguments().getParcelable(ARG_URI))
|
||||
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))
|
||||
|
||||
companion object {
|
||||
private const val ARG_URI = "arg.uri"
|
||||
private const val ARG_IS_VIDEO_GIF = "arg.is.video.gif"
|
||||
|
||||
fun newInstance(uri: Uri, isVideoGif: Boolean): Fragment {
|
||||
return MediaReviewVideoPageFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_URI, uri)
|
||||
putBoolean(ARG_IS_VIDEO_GIF, isVideoGif)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ import org.thoughtcrime.securesms.components.location.SignalMapView;
|
||||
import org.thoughtcrime.securesms.components.location.SignalPlace;
|
||||
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
|
||||
import org.thoughtcrime.securesms.maps.PlacePickerActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
|
||||
import org.thoughtcrime.securesms.payments.create.CreatePaymentFragmentArgs;
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
@@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -372,12 +373,12 @@ public class AttachmentManager {
|
||||
selectMediaType(activity, "*/*", null, requestCode);
|
||||
}
|
||||
|
||||
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull CharSequence body, @NonNull TransportOption transport) {
|
||||
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull CharSequence body, @NonNull TransportOption transport, boolean hasQuote) {
|
||||
Permissions.with(activity)
|
||||
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
|
||||
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body, transport), requestCode))
|
||||
.onAllGranted(() -> activity.startActivityForResult(MediaSelectionActivity.gallery(activity, transport, Collections.emptyList(), recipient.getId(), body, hasQuote), requestCode))
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package org.thoughtcrime.securesms.scribbles
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.PointF
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.widget.SeekBar
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.appcompat.widget.AppCompatSeekBar
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.customizeOnDraw
|
||||
|
||||
/**
|
||||
* One stop shop to turn an AppCompatSeekBar into an HSV Color Slider.
|
||||
*/
|
||||
object HSVColorSlider {
|
||||
|
||||
private const val MAX_SEEK_DIVISIONS = 1023
|
||||
private const val MAX_HUE = 360
|
||||
|
||||
private val colors: IntArray = (0..MAX_SEEK_DIVISIONS).map { hue ->
|
||||
ColorUtils.HSLToColor(
|
||||
floatArrayOf(
|
||||
hue.toHue(MAX_SEEK_DIVISIONS),
|
||||
1f,
|
||||
calculateLightness(hue.toFloat(), 0.4f)
|
||||
)
|
||||
)
|
||||
}.toIntArray()
|
||||
|
||||
fun AppCompatSeekBar.getColor(): Int {
|
||||
return colors[progress]
|
||||
}
|
||||
|
||||
fun AppCompatSeekBar.setColor(color: Int) {
|
||||
val index = colors.indexOf(color)
|
||||
progress = if (index >= 0) {
|
||||
index
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun AppCompatSeekBar.setUpForColor(
|
||||
@ColorInt thumbBorderColor: Int,
|
||||
onColorChanged: (Int) -> Unit,
|
||||
onDragStart: () -> Unit,
|
||||
onDragEnd: () -> Unit
|
||||
) {
|
||||
max = MAX_SEEK_DIVISIONS
|
||||
thumb = createThumbDrawable(thumbBorderColor)
|
||||
progressDrawable = createColorProgressDrawable()
|
||||
setOnSeekBarChangeListener(
|
||||
object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
val color = colors[progress]
|
||||
(thumb as ThumbDrawable).setColor(color)
|
||||
onColorChanged(color)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) {
|
||||
onDragStart()
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {
|
||||
onDragEnd()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
progress = 0
|
||||
(thumb as ThumbDrawable).setColor(colors[progress])
|
||||
}
|
||||
|
||||
fun createThumbDrawable(@ColorInt borderColor: Int): Drawable {
|
||||
return ThumbDrawable(borderColor)
|
||||
}
|
||||
|
||||
private fun createColorProgressDrawable(): Drawable {
|
||||
return GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, colors).forSeekBar()
|
||||
}
|
||||
|
||||
private fun calculateLightness(hue: Float, valueFor60To80: Float = 0.3f): Float {
|
||||
|
||||
val point1 = PointF()
|
||||
val point2 = PointF()
|
||||
|
||||
if (hue >= 0f && hue < 60f) {
|
||||
point1.set(0f, 0.45f)
|
||||
point2.set(60f, valueFor60To80)
|
||||
} else if (hue >= 60f && hue < 180f) {
|
||||
return valueFor60To80
|
||||
} else if (hue >= 180f && hue < 240f) {
|
||||
point1.set(180f, valueFor60To80)
|
||||
point2.set(240f, 0.5f)
|
||||
} else if (hue >= 240f && hue < 300f) {
|
||||
point1.set(240f, 0.5f)
|
||||
point2.set(300f, 0.4f)
|
||||
} else if (hue >= 300f && hue < 360f) {
|
||||
point1.set(300f, 0.4f)
|
||||
point2.set(360f, 0.45f)
|
||||
} else {
|
||||
return 0.45f
|
||||
}
|
||||
|
||||
return interpolate(point1, point2, hue)
|
||||
}
|
||||
|
||||
private fun interpolate(point1: PointF, point2: PointF, x: Float): Float {
|
||||
return ((point1.y * (point2.x - x)) + (point2.y * (x - point1.x))) / (point2.x - point1.x)
|
||||
}
|
||||
|
||||
private fun Number.toHue(max: Number): Float {
|
||||
return Util.clamp(toFloat() * (MAX_HUE / max.toFloat()), 0f, MAX_HUE.toFloat())
|
||||
}
|
||||
|
||||
private fun Drawable.forSeekBar(): Drawable {
|
||||
val height: Int = ViewUtil.dpToPx(1)
|
||||
val radii: FloatArray = (1..8).map { 50f }.toFloatArray()
|
||||
val bounds = RectF()
|
||||
val clipPath = Path()
|
||||
|
||||
return customizeOnDraw { wrapped, canvas ->
|
||||
canvas.save()
|
||||
bounds.set(this.bounds)
|
||||
bounds.inset(0f, (height / 2f) + 1)
|
||||
|
||||
clipPath.rewind()
|
||||
clipPath.addRoundRect(bounds, radii, Path.Direction.CW)
|
||||
|
||||
canvas.clipPath(clipPath)
|
||||
wrapped.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private class ThumbDrawable(@ColorInt borderColor: Int) : Drawable() {
|
||||
|
||||
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = borderColor
|
||||
}
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.TRANSPARENT
|
||||
}
|
||||
|
||||
private val borderWidth: Int = ViewUtil.dpToPx(THUMB_MARGIN)
|
||||
private val thumbInnerSize: Int = ViewUtil.dpToPx(THUMB_INNER_SIZE)
|
||||
private val innerRadius: Float = thumbInnerSize / 2f
|
||||
private val thumbSize: Float = (thumbInnerSize + borderWidth).toFloat()
|
||||
private val thumbRadius: Float = thumbSize / 2f
|
||||
|
||||
override fun getIntrinsicHeight(): Int = ViewUtil.dpToPx(48)
|
||||
|
||||
override fun getIntrinsicWidth(): Int = ViewUtil.dpToPx(48)
|
||||
|
||||
fun setColor(@ColorInt color: Int) {
|
||||
paint.color = color
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.drawCircle(
|
||||
(bounds.width() / 2f) + bounds.left,
|
||||
(bounds.height() / 2f) + bounds.top,
|
||||
thumbRadius,
|
||||
borderPaint
|
||||
)
|
||||
canvas.drawCircle(
|
||||
(bounds.width() / 2f) + bounds.left,
|
||||
(bounds.height() / 2f) + bounds.top,
|
||||
innerRadius,
|
||||
paint
|
||||
)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSPARENT
|
||||
|
||||
companion object {
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val THUMB_INNER_SIZE = 4
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val THUMB_MARGIN = 24
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
@@ -14,15 +16,23 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.engine.GlideException;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.ResizeAnimation;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.ColorableRenderer;
|
||||
@@ -39,12 +49,12 @@ import org.thoughtcrime.securesms.mms.PushMediaConstraints;
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.StorageUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
@@ -54,16 +64,15 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
public final class ImageEditorFragment extends Fragment implements ImageEditorHud.EventListener,
|
||||
VerticalSlideColorPicker.OnColorChangeListener,
|
||||
MediaSendPageFragment {
|
||||
public final class ImageEditorFragment extends Fragment implements ImageEditorHudV2.EventListener,
|
||||
MediaSendPageFragment,
|
||||
TextEntryDialogFragment.Controller
|
||||
{
|
||||
|
||||
private static final String TAG = Log.tag(ImageEditorFragment.class);
|
||||
|
||||
private static final String KEY_IMAGE_URI = "image_uri";
|
||||
private static final String KEY_MODE = "mode";
|
||||
private static final String KEY_IMAGE_URI = "image_uri";
|
||||
private static final String KEY_MODE = "mode";
|
||||
|
||||
private static final int SELECT_STICKER_REQUEST_CODE = 124;
|
||||
|
||||
@@ -72,13 +81,13 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
private Pair<Uri, FaceDetectionResult> cachedFaceDetection;
|
||||
|
||||
@Nullable private EditorElement currentSelection;
|
||||
private int imageMaxHeight;
|
||||
private int imageMaxWidth;
|
||||
private int imageMaxHeight;
|
||||
private int imageMaxWidth;
|
||||
|
||||
public static class Data {
|
||||
private final Bundle bundle;
|
||||
|
||||
Data(Bundle bundle) {
|
||||
public Data(Bundle bundle) {
|
||||
this.bundle = bundle;
|
||||
}
|
||||
|
||||
@@ -99,12 +108,17 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
}
|
||||
return ParcelUtil.deserialize(bytes, EditorModel.CREATOR);
|
||||
}
|
||||
|
||||
public @NonNull Bundle getBundle() {
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
|
||||
private Uri imageUri;
|
||||
private Controller controller;
|
||||
private ImageEditorHud imageEditorHud;
|
||||
private ImageEditorHudV2 imageEditorHud;
|
||||
private ImageEditorView imageEditorView;
|
||||
private boolean hasMadeAnEditThisSession;
|
||||
|
||||
public static ImageEditorFragment newInstanceForAvatarCapture(@NonNull Uri imageUri) {
|
||||
ImageEditorFragment fragment = newInstance(imageUri);
|
||||
@@ -129,6 +143,15 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
return fragment;
|
||||
}
|
||||
|
||||
public void setMode(ImageEditorHudV2.Mode mode) {
|
||||
ImageEditorHudV2.Mode currentMode = imageEditorHud.getMode();
|
||||
if (currentMode == mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
imageEditorHud.setMode(mode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -139,7 +162,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
} else if (getActivity() instanceof Controller) {
|
||||
controller = (Controller) getActivity();
|
||||
} else {
|
||||
throw new IllegalStateException("Parent activity must implement Controller interface.");
|
||||
throw new IllegalStateException("Parent must implement Controller interface.");
|
||||
}
|
||||
|
||||
Bundle arguments = getArguments();
|
||||
@@ -172,16 +195,21 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
imageEditorHud = view.findViewById(R.id.scribble_hud);
|
||||
imageEditorView = view.findViewById(R.id.image_editor_view);
|
||||
|
||||
int width = getResources().getDisplayMetrics().widthPixels;
|
||||
imageEditorView.setMinimumHeight((int) ((16 / 9f) * width));
|
||||
imageEditorView.requestLayout();
|
||||
|
||||
imageEditorHud.setEventListener(this);
|
||||
|
||||
imageEditorView.setDrawListener(drawListener);
|
||||
imageEditorView.setTapListener(selectionListener);
|
||||
imageEditorView.setDrawingChangedListener(this::refreshUniqueColors);
|
||||
imageEditorView.setDrawingChangedListener(stillTouching -> onDrawingChanged(stillTouching, true));
|
||||
imageEditorView.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged);
|
||||
|
||||
EditorModel editorModel = null;
|
||||
|
||||
if (restoredModel != null) {
|
||||
editorModel = restoredModel;
|
||||
editorModel = restoredModel;
|
||||
restoredModel = null;
|
||||
}
|
||||
|
||||
@@ -198,9 +226,11 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
break;
|
||||
}
|
||||
|
||||
EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight));
|
||||
EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight, UriGlideRenderer.STRONG_BLUR, mainImageRequestListener));
|
||||
image.getFlags().setSelectable(false).persist();
|
||||
editorModel.addElement(image);
|
||||
} else {
|
||||
controller.onMainImageLoaded();
|
||||
}
|
||||
|
||||
if (mode == Mode.AVATAR_CAPTURE || mode == Mode.AVATAR_EDIT) {
|
||||
@@ -208,7 +238,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
}
|
||||
|
||||
if (mode == Mode.AVATAR_CAPTURE) {
|
||||
imageEditorHud.enterMode(ImageEditorHud.Mode.CROP);
|
||||
imageEditorHud.enterMode(ImageEditorHudV2.Mode.CROP);
|
||||
}
|
||||
|
||||
imageEditorView.setModel(editorModel);
|
||||
@@ -218,7 +248,9 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
SignalStore.tooltips().markBlurHudIconTooltipSeen();
|
||||
}
|
||||
|
||||
refreshUniqueColors();
|
||||
onDrawingChanged(false, false);
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressedCallback);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -255,7 +287,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
if (model != null) {
|
||||
if (imageEditorView != null) {
|
||||
imageEditorView.setModel(model);
|
||||
refreshUniqueColors();
|
||||
onDrawingChanged(false, false);
|
||||
} else {
|
||||
this.restoredModel = model;
|
||||
}
|
||||
@@ -274,13 +306,36 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
Renderer renderer = currentSelection.getRenderer();
|
||||
if (renderer instanceof ColorableRenderer) {
|
||||
((ColorableRenderer) renderer).setColor(selectedColor);
|
||||
refreshUniqueColors();
|
||||
onDrawingChanged(false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startTextEntityEditing(@NonNull EditorElement textElement, boolean selectAll) {
|
||||
imageEditorView.startTextEditing(textElement, TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()), selectAll);
|
||||
imageEditorView.startTextEditing(textElement);
|
||||
|
||||
TextEntryDialogFragment.Companion.show(
|
||||
getChildFragmentManager(),
|
||||
textElement,
|
||||
TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()),
|
||||
selectAll,
|
||||
imageEditorHud.getColorIndex()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) {
|
||||
imageEditorView.zoomToFitText(editorElement, textRenderer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextEntryDialogDismissed(boolean hasText) {
|
||||
imageEditorView.doneTextEditing();
|
||||
|
||||
if (!hasText) {
|
||||
onUndo();
|
||||
imageEditorHud.setMode(ImageEditorHudV2.Mode.DRAW);
|
||||
}
|
||||
}
|
||||
|
||||
protected void addText() {
|
||||
@@ -307,19 +362,32 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
EditorElement element = new EditorElement(renderer, EditorModel.Z_STICKERS);
|
||||
imageEditorView.getModel().addElementCentered(element, 0.2f);
|
||||
currentSelection = element;
|
||||
imageEditorHud.setMode(ImageEditorHud.Mode.MOVE_DELETE);
|
||||
hasMadeAnEditThisSession = true;
|
||||
}
|
||||
} else {
|
||||
imageEditorHud.setMode(ImageEditorHud.Mode.NONE);
|
||||
imageEditorHud.setMode(ImageEditorHudV2.Mode.DRAW);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModeStarted(@NonNull ImageEditorHud.Mode mode) {
|
||||
public void onModeStarted(@NonNull ImageEditorHudV2.Mode mode, @NonNull ImageEditorHudV2.Mode previousMode) {
|
||||
onBackPressedCallback.setEnabled(shouldHandleOnBackPressed(mode));
|
||||
|
||||
imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize);
|
||||
imageEditorView.doneTextEditing();
|
||||
|
||||
controller.onTouchEventsNeeded(mode != ImageEditorHud.Mode.NONE);
|
||||
controller.onTouchEventsNeeded(mode != ImageEditorHudV2.Mode.NONE);
|
||||
|
||||
boolean shouldScaleViewPortForCurrentMode = shouldScaleViewPort(mode);
|
||||
boolean shouldScaleViewPortForPreviousMode = shouldScaleViewPort(previousMode);
|
||||
|
||||
if (shouldScaleViewPortForCurrentMode != shouldScaleViewPortForPreviousMode) {
|
||||
if (shouldScaleViewPortForCurrentMode) {
|
||||
scaleViewPortForDrawing();
|
||||
} else {
|
||||
restoreViewPortScaling();
|
||||
}
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case CROP: {
|
||||
@@ -327,18 +395,14 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
break;
|
||||
}
|
||||
|
||||
case DRAW: {
|
||||
imageEditorView.startDrawing(0.01f, Paint.Cap.ROUND, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case DRAW:
|
||||
case HIGHLIGHT: {
|
||||
imageEditorView.startDrawing(0.03f, Paint.Cap.SQUARE, false);
|
||||
onBrushWidthChange(imageEditorHud.getActiveBrushWidth());
|
||||
break;
|
||||
}
|
||||
|
||||
case BLUR: {
|
||||
imageEditorView.startDrawing(0.052f, Paint.Cap.ROUND, true);
|
||||
onBrushWidthChange(imageEditorHud.getActiveBrushWidth());
|
||||
imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer());
|
||||
break;
|
||||
}
|
||||
@@ -360,6 +424,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
case NONE: {
|
||||
imageEditorView.getModel().doneCrop();
|
||||
currentSelection = null;
|
||||
hasMadeAnEditThisSession = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -371,6 +436,23 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
changeEntityColor(color);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextColorChange(int colorIndex) {
|
||||
imageEditorHud.setColorIndex(colorIndex);
|
||||
onColorChange(imageEditorHud.getActiveColor());
|
||||
}
|
||||
|
||||
private static final float MINIMUM_DRAW_WIDTH = 0.01f;
|
||||
private static final float MAXIMUM_DRAW_WIDTH = 0.05f;
|
||||
|
||||
@Override
|
||||
public void onBrushWidthChange(int widthPercentage) {
|
||||
ImageEditorHudV2.Mode mode = imageEditorHud.getMode();
|
||||
|
||||
float interpolatedWidth = MINIMUM_DRAW_WIDTH + (MAXIMUM_DRAW_WIDTH - MINIMUM_DRAW_WIDTH) * (widthPercentage / 100f);
|
||||
imageEditorView.startDrawing(interpolatedWidth, mode == ImageEditorHudV2.Mode.HIGHLIGHT ? Paint.Cap.SQUARE : Paint.Cap.ROUND, mode == ImageEditorHudV2.Mode.BLUR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBlurFacesToggled(boolean enabled) {
|
||||
EditorModel model = imageEditorView.getModel();
|
||||
@@ -407,7 +489,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
if (bitmap != null) {
|
||||
FaceDetector detector = new AndroidFaceDetector();
|
||||
|
||||
Point size = model.getOutputSizeMaxWidth(1000);
|
||||
Point size = model.getOutputSizeMaxWidth(1000);
|
||||
Bitmap render = model.render(ApplicationDependencies.getApplication(), size);
|
||||
try {
|
||||
return new FaceDetectionResult(detector.detect(render), new Point(render.getWidth(), render.getHeight()), inverseCropPosition);
|
||||
@@ -427,17 +509,40 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onClearAll() {
|
||||
imageEditorView.getModel().clearUndoStack();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel() {
|
||||
if (hasMadeAnEditThisSession) {
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.MediaReviewImagePageFragment__discard_changes)
|
||||
.setMessage(R.string.MediaReviewImagePageFragment__youll_lose_any_changes)
|
||||
.setPositiveButton(R.string.MediaReviewImagePageFragment__discard, (d, w) -> {
|
||||
d.dismiss();
|
||||
imageEditorHud.setMode(ImageEditorHudV2.Mode.NONE);
|
||||
controller.onCancelEditing();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, (d, w) -> d.dismiss())
|
||||
.show();
|
||||
} else {
|
||||
imageEditorHud.setMode(ImageEditorHudV2.Mode.NONE);
|
||||
controller.onCancelEditing();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUndo() {
|
||||
imageEditorView.getModel().undo();
|
||||
refreshUniqueColors();
|
||||
imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDelete() {
|
||||
imageEditorView.deleteElement(currentSelection);
|
||||
refreshUniqueColors();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -469,8 +574,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCropAspectLock(boolean locked) {
|
||||
imageEditorView.getModel().setCropAspectLock(locked);
|
||||
public void onCropAspectLock() {
|
||||
imageEditorView.getModel().setCropAspectLock(!imageEditorView.getModel().isCropAspectLocked());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -488,6 +593,42 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
controller.onDoneEditing();
|
||||
}
|
||||
|
||||
private ResizeAnimation resizeAnimation;
|
||||
|
||||
private void scaleViewPortForDrawing() {
|
||||
if (resizeAnimation != null) {
|
||||
resizeAnimation.cancel();
|
||||
}
|
||||
|
||||
float aspectRatio = 9 / 16f;
|
||||
int targetWidth = requireView().getMeasuredWidth() - ViewUtil.dpToPx(32);
|
||||
int targetHeight = (int) ((1 / aspectRatio) * targetWidth);
|
||||
|
||||
if (targetWidth < requireView().getMeasuredWidth()) {
|
||||
resizeAnimation = new ResizeAnimation(imageEditorView, targetWidth, targetHeight);
|
||||
resizeAnimation.setDuration(250);
|
||||
imageEditorView.startAnimation(resizeAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
private void restoreViewPortScaling() {
|
||||
if (resizeAnimation != null) {
|
||||
resizeAnimation.cancel();
|
||||
}
|
||||
|
||||
float aspectRatio = 9 / 16f;
|
||||
int targetWidth = requireView().getMeasuredWidth();
|
||||
int targetHeight = (int) ((1 / aspectRatio) * targetWidth);
|
||||
|
||||
resizeAnimation = new ResizeAnimation(imageEditorView, targetWidth, targetHeight);
|
||||
resizeAnimation.setDuration(250);
|
||||
imageEditorView.startAnimation(resizeAnimation);
|
||||
}
|
||||
|
||||
private static boolean shouldScaleViewPort(@NonNull ImageEditorHudV2.Mode mode) {
|
||||
return mode != ImageEditorHudV2.Mode.NONE;
|
||||
}
|
||||
|
||||
private void performSaveToDisk() {
|
||||
SimpleTask.run(this::renderToSingleUseBlob, uri -> {
|
||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(requireContext());
|
||||
@@ -510,8 +651,14 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
.createForSingleUseInMemory();
|
||||
}
|
||||
|
||||
private void refreshUniqueColors() {
|
||||
imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha());
|
||||
private void onDrawingChanged(boolean stillTouching, boolean isUserEdit) {
|
||||
if (isUserEdit) {
|
||||
hasMadeAnEditThisSession = true;
|
||||
}
|
||||
|
||||
if (!stillTouching && shouldExitModeOnChange(imageEditorHud.getMode())) {
|
||||
onPopEditorMode();
|
||||
}
|
||||
}
|
||||
|
||||
private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) {
|
||||
@@ -531,9 +678,9 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
Matrix faceMatrix = new Matrix();
|
||||
|
||||
for (FaceDetector.Face face : faces) {
|
||||
Renderer faceBlurRenderer = new FaceBlurRenderer();
|
||||
EditorElement element = new EditorElement(faceBlurRenderer, EditorModel.Z_MASK);
|
||||
Matrix localMatrix = element.getLocalMatrix();
|
||||
Renderer faceBlurRenderer = new FaceBlurRenderer();
|
||||
EditorElement element = new EditorElement(faceBlurRenderer, EditorModel.Z_MASK);
|
||||
Matrix localMatrix = element.getLocalMatrix();
|
||||
|
||||
faceMatrix.setRectToRect(Bounds.FULL_BOUNDS, face.getBounds(), Matrix.ScaleToFit.FILL);
|
||||
|
||||
@@ -541,8 +688,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
localMatrix.preConcat(faceMatrix);
|
||||
|
||||
element.getFlags().setEditable(false)
|
||||
.setSelectable(false)
|
||||
.persist();
|
||||
.setSelectable(false)
|
||||
.persist();
|
||||
|
||||
imageEditorView.getModel().addElementWithoutPushUndo(element);
|
||||
}
|
||||
@@ -552,51 +699,126 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
cachedFaceDetection = new Pair<>(getUri(), result);
|
||||
}
|
||||
|
||||
private boolean shouldHandleOnBackPressed(ImageEditorHudV2.Mode mode) {
|
||||
return mode == ImageEditorHudV2.Mode.CROP ||
|
||||
mode == ImageEditorHudV2.Mode.DRAW ||
|
||||
mode == ImageEditorHudV2.Mode.HIGHLIGHT ||
|
||||
mode == ImageEditorHudV2.Mode.BLUR ||
|
||||
mode == ImageEditorHudV2.Mode.TEXT ||
|
||||
mode == ImageEditorHudV2.Mode.MOVE_DELETE ||
|
||||
mode == ImageEditorHudV2.Mode.INSERT_STICKER;
|
||||
}
|
||||
|
||||
private boolean shouldExitModeOnChange(ImageEditorHudV2.Mode mode) {
|
||||
return mode == ImageEditorHudV2.Mode.MOVE_DELETE || mode == ImageEditorHudV2.Mode.INSERT_STICKER;
|
||||
}
|
||||
|
||||
private void onPopEditorMode() {
|
||||
currentSelection = null;
|
||||
|
||||
switch (imageEditorHud.getMode()) {
|
||||
case NONE:
|
||||
return;
|
||||
case CROP:
|
||||
case DRAW:
|
||||
case HIGHLIGHT:
|
||||
case BLUR:
|
||||
onCancel();
|
||||
break;
|
||||
case INSERT_STICKER:
|
||||
case TEXT:
|
||||
controller.onTouchEventsNeeded(true);
|
||||
imageEditorHud.setMode(ImageEditorHudV2.Mode.DRAW);
|
||||
break;
|
||||
case MOVE_DELETE:
|
||||
onDone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private final RequestListener<Bitmap> mainImageRequestListener = new RequestListener<Bitmap>() {
|
||||
@Override public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Bitmap> target, boolean isFirstResource) {
|
||||
controller.onMainImageFailedToLoad();
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onResourceReady(Bitmap resource, Object model, Target<Bitmap> target, DataSource dataSource, boolean isFirstResource) {
|
||||
controller.onMainImageLoaded();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
private final ImageEditorView.DrawListener drawListener = new ImageEditorView.DrawListener() {
|
||||
@Override
|
||||
public void onDrawStarted() {
|
||||
imageEditorHud.animate().alpha(0f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDrawEnded() {
|
||||
imageEditorHud.animate().alpha(1f);
|
||||
}
|
||||
};
|
||||
|
||||
private final ImageEditorView.TapListener selectionListener = new ImageEditorView.TapListener() {
|
||||
|
||||
@Override
|
||||
public void onEntityDown(@Nullable EditorElement editorElement) {
|
||||
if (editorElement != null) {
|
||||
controller.onTouchEventsNeeded(true);
|
||||
} else {
|
||||
currentSelection = null;
|
||||
controller.onTouchEventsNeeded(false);
|
||||
imageEditorHud.setMode(ImageEditorHud.Mode.NONE);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onEntityDown(@Nullable EditorElement editorElement) {
|
||||
if (editorElement != null) {
|
||||
controller.onTouchEventsNeeded(true);
|
||||
|
||||
@Override
|
||||
public void onEntitySingleTap(@Nullable EditorElement editorElement) {
|
||||
currentSelection = editorElement;
|
||||
if (currentSelection != null) {
|
||||
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
|
||||
setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), imageEditorView.isTextEditing());
|
||||
} else {
|
||||
imageEditorHud.setMode(ImageEditorHud.Mode.MOVE_DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean isMoveableElement = editorElement.getZOrder() == EditorModel.Z_STICKERS ||
|
||||
editorElement.getZOrder() == EditorModel.Z_TEXT;
|
||||
|
||||
@Override
|
||||
public void onEntityDoubleTap(@NonNull EditorElement editorElement) {
|
||||
currentSelection = editorElement;
|
||||
boolean notInsertSticker = imageEditorHud.getMode() != ImageEditorHudV2.Mode.INSERT_STICKER;
|
||||
|
||||
if (isMoveableElement && notInsertSticker) {
|
||||
imageEditorHud.setMode(ImageEditorHudV2.Mode.MOVE_DELETE);
|
||||
}
|
||||
} else {
|
||||
onPopEditorMode();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEntitySingleTap(@Nullable EditorElement editorElement) {
|
||||
currentSelection = editorElement;
|
||||
if (currentSelection != null) {
|
||||
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
|
||||
setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), true);
|
||||
setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), imageEditorView.isTextEditing());
|
||||
} else {
|
||||
imageEditorHud.setMode(ImageEditorHudV2.Mode.MOVE_DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setTextElement(@NonNull EditorElement editorElement,
|
||||
@NonNull ColorableRenderer colorableRenderer,
|
||||
boolean startEditing)
|
||||
{
|
||||
int color = colorableRenderer.getColor();
|
||||
imageEditorHud.enterMode(ImageEditorHud.Mode.TEXT);
|
||||
imageEditorHud.setActiveColor(color);
|
||||
if (startEditing) {
|
||||
startTextEntityEditing(editorElement, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
@Override
|
||||
public void onEntityDoubleTap(@NonNull EditorElement editorElement) {
|
||||
currentSelection = editorElement;
|
||||
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
|
||||
setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setTextElement(@NonNull EditorElement editorElement,
|
||||
@NonNull ColorableRenderer colorableRenderer,
|
||||
boolean startEditing)
|
||||
{
|
||||
int color = colorableRenderer.getColor();
|
||||
imageEditorHud.enterMode(ImageEditorHudV2.Mode.TEXT);
|
||||
imageEditorHud.setActiveColor(color);
|
||||
if (startEditing) {
|
||||
startTextEntityEditing(editorElement, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
onPopEditorMode();
|
||||
}
|
||||
};
|
||||
|
||||
public interface Controller {
|
||||
void onTouchEventsNeeded(boolean needed);
|
||||
@@ -604,6 +826,12 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||
void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard);
|
||||
|
||||
void onDoneEditing();
|
||||
|
||||
void onCancelEditing();
|
||||
|
||||
void onMainImageLoaded();
|
||||
|
||||
void onMainImageFailedToLoad();
|
||||
}
|
||||
|
||||
private static class FaceDetectionResult {
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Switch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.ColorPaletteAdapter;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The HUD (heads-up display) that contains all of the tools for interacting with
|
||||
* {@link org.thoughtcrime.securesms.imageeditor.ImageEditorView}
|
||||
*/
|
||||
public final class ImageEditorHud extends LinearLayout {
|
||||
|
||||
private View cropButton;
|
||||
private View cropFlipButton;
|
||||
private View cropRotateButton;
|
||||
private ImageView cropAspectLock;
|
||||
private View drawButton;
|
||||
private View highlightButton;
|
||||
private View blurButton;
|
||||
private View textButton;
|
||||
private View stickerButton;
|
||||
private View undoButton;
|
||||
private View saveButton;
|
||||
private View deleteButton;
|
||||
private View confirmButton;
|
||||
private View doneButton;
|
||||
private View blurToggleHud;
|
||||
private Switch blurToggle;
|
||||
private View blurToast;
|
||||
private VerticalSlideColorPicker colorPicker;
|
||||
private RecyclerView colorPalette;
|
||||
|
||||
|
||||
@NonNull
|
||||
private EventListener eventListener = NULL_EVENT_LISTENER;
|
||||
@Nullable
|
||||
private ColorPaletteAdapter colorPaletteAdapter;
|
||||
|
||||
private final Map<Mode, Set<View>> visibilityModeMap = new HashMap<>();
|
||||
private final Set<View> allViews = new HashSet<>();
|
||||
private final Debouncer toastDebouncer = new Debouncer(3000);
|
||||
|
||||
private Mode currentMode;
|
||||
private boolean undoAvailable;
|
||||
|
||||
public ImageEditorHud(@NonNull Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
inflate(getContext(), R.layout.image_editor_hud, this);
|
||||
setOrientation(VERTICAL);
|
||||
|
||||
cropButton = findViewById(R.id.scribble_crop_button);
|
||||
cropFlipButton = findViewById(R.id.scribble_crop_flip);
|
||||
cropRotateButton = findViewById(R.id.scribble_crop_rotate);
|
||||
cropAspectLock = findViewById(R.id.scribble_crop_aspect_lock);
|
||||
colorPalette = findViewById(R.id.scribble_color_palette);
|
||||
drawButton = findViewById(R.id.scribble_draw_button);
|
||||
highlightButton = findViewById(R.id.scribble_highlight_button);
|
||||
blurButton = findViewById(R.id.scribble_blur_button);
|
||||
textButton = findViewById(R.id.scribble_text_button);
|
||||
stickerButton = findViewById(R.id.scribble_sticker_button);
|
||||
undoButton = findViewById(R.id.scribble_undo_button);
|
||||
saveButton = findViewById(R.id.scribble_save_button);
|
||||
deleteButton = findViewById(R.id.scribble_delete_button);
|
||||
confirmButton = findViewById(R.id.scribble_confirm_button);
|
||||
colorPicker = findViewById(R.id.scribble_color_picker);
|
||||
doneButton = findViewById(R.id.scribble_done_button);
|
||||
blurToggleHud = findViewById(R.id.scribble_blur_toggle_hud);
|
||||
blurToggle = findViewById(R.id.scribble_blur_toggle);
|
||||
blurToast = findViewById(R.id.scribble_blur_toast);
|
||||
|
||||
cropAspectLock.setOnClickListener(v -> {
|
||||
eventListener.onCropAspectLock(!eventListener.isCropAspectLocked());
|
||||
updateCropAspectLockImage(eventListener.isCropAspectLocked());
|
||||
});
|
||||
|
||||
initializeViews();
|
||||
initializeVisibilityMap();
|
||||
setMode(Mode.NONE);
|
||||
}
|
||||
|
||||
private void updateCropAspectLockImage(boolean cropAspectLocked) {
|
||||
cropAspectLock.setImageDrawable(getResources().getDrawable(cropAspectLocked ? R.drawable.ic_crop_lock_32 : R.drawable.ic_crop_unlock_32));
|
||||
}
|
||||
|
||||
private void initializeVisibilityMap() {
|
||||
setVisibleViewsWhenInMode(Mode.NONE, drawButton, blurButton, textButton, stickerButton, cropButton, undoButton, saveButton);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.DRAW, confirmButton, undoButton, colorPicker, colorPalette, highlightButton);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.HIGHLIGHT, confirmButton, undoButton, colorPicker, colorPalette, drawButton);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.BLUR, confirmButton, undoButton, blurToggleHud);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.TEXT, confirmButton, deleteButton, colorPicker, colorPalette);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.MOVE_DELETE, confirmButton, deleteButton);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.INSERT_STICKER, confirmButton);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.CROP, confirmButton, cropFlipButton, cropRotateButton, cropAspectLock, undoButton);
|
||||
|
||||
for (Set<View> views : visibilityModeMap.values()) {
|
||||
allViews.addAll(views);
|
||||
}
|
||||
|
||||
allViews.add(stickerButton);
|
||||
allViews.add(doneButton);
|
||||
}
|
||||
|
||||
private void setVisibleViewsWhenInMode(Mode mode, View... views) {
|
||||
visibilityModeMap.put(mode, new HashSet<>(Arrays.asList(views)));
|
||||
}
|
||||
|
||||
private void initializeViews() {
|
||||
undoButton.setOnClickListener(v -> eventListener.onUndo());
|
||||
|
||||
deleteButton.setOnClickListener(v -> {
|
||||
eventListener.onDelete();
|
||||
setMode(Mode.NONE);
|
||||
});
|
||||
|
||||
cropButton.setOnClickListener(v -> setMode(Mode.CROP));
|
||||
cropFlipButton.setOnClickListener(v -> eventListener.onFlipHorizontal());
|
||||
cropRotateButton.setOnClickListener(v -> eventListener.onRotate90AntiClockwise());
|
||||
|
||||
confirmButton.setOnClickListener(v -> setMode(Mode.NONE));
|
||||
|
||||
colorPaletteAdapter = new ColorPaletteAdapter();
|
||||
colorPaletteAdapter.setEventListener(colorPicker::setActiveColor);
|
||||
|
||||
colorPalette.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
colorPalette.setAdapter(colorPaletteAdapter);
|
||||
|
||||
drawButton.setOnClickListener(v -> setMode(Mode.DRAW));
|
||||
blurButton.setOnClickListener(v -> setMode(Mode.BLUR));
|
||||
highlightButton.setOnClickListener(v -> setMode(Mode.HIGHLIGHT));
|
||||
textButton.setOnClickListener(v -> setMode(Mode.TEXT));
|
||||
stickerButton.setOnClickListener(v -> setMode(Mode.INSERT_STICKER));
|
||||
saveButton.setOnClickListener(v -> eventListener.onSave());
|
||||
doneButton.setOnClickListener(v -> eventListener.onDone());
|
||||
blurToggle.setOnCheckedChangeListener((button, enabled) -> eventListener.onBlurFacesToggled(enabled));
|
||||
}
|
||||
|
||||
public void setUpForAvatarEditing() {
|
||||
visibilityModeMap.get(Mode.NONE).add(doneButton);
|
||||
visibilityModeMap.get(Mode.NONE).remove(saveButton);
|
||||
visibilityModeMap.get(Mode.CROP).remove(cropAspectLock);
|
||||
|
||||
if (currentMode == Mode.NONE) {
|
||||
doneButton.setVisibility(View.VISIBLE);
|
||||
saveButton.setVisibility(View.GONE);
|
||||
} else if (currentMode == Mode.CROP) {
|
||||
cropAspectLock.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setColorPalette(@NonNull Set<Integer> colors) {
|
||||
if (colorPaletteAdapter != null) {
|
||||
colorPaletteAdapter.setColors(colors);
|
||||
}
|
||||
}
|
||||
|
||||
public int getActiveColor() {
|
||||
return colorPicker.getActiveColor();
|
||||
}
|
||||
|
||||
public void setActiveColor(int color) {
|
||||
colorPicker.setActiveColor(color);
|
||||
}
|
||||
|
||||
public void setBlurFacesToggleEnabled(boolean enabled) {
|
||||
blurToggle.setOnCheckedChangeListener(null);
|
||||
blurToggle.setChecked(enabled);
|
||||
blurToggle.setOnCheckedChangeListener((button, value) -> eventListener.onBlurFacesToggled(value));
|
||||
}
|
||||
|
||||
public void showBlurHudTooltip() {
|
||||
TooltipPopup.forTarget(blurButton)
|
||||
.setText(R.string.ImageEditorHud_new_blur_faces_or_draw_anywhere_to_blur)
|
||||
.setBackgroundTint(ContextCompat.getColor(getContext(), R.color.core_ultramarine))
|
||||
.setTextColor(ContextCompat.getColor(getContext(), R.color.core_white))
|
||||
.show(TooltipPopup.POSITION_BELOW);
|
||||
}
|
||||
|
||||
public void showBlurToast() {
|
||||
blurToast.clearAnimation();
|
||||
blurToast.setVisibility(View.VISIBLE);
|
||||
toastDebouncer.publish(() -> blurToast.setVisibility(GONE));
|
||||
}
|
||||
|
||||
public void hideBlurToast() {
|
||||
blurToast.clearAnimation();
|
||||
blurToast.setVisibility(View.GONE);
|
||||
toastDebouncer.clear();
|
||||
}
|
||||
|
||||
public void setEventListener(@Nullable EventListener eventListener) {
|
||||
this.eventListener = eventListener != null ? eventListener : NULL_EVENT_LISTENER;
|
||||
}
|
||||
|
||||
public void enterMode(@NonNull Mode mode) {
|
||||
setMode(mode, false);
|
||||
}
|
||||
|
||||
public void setMode(@NonNull Mode mode) {
|
||||
setMode(mode, true);
|
||||
}
|
||||
|
||||
private void setMode(@NonNull Mode mode, boolean notify) {
|
||||
this.currentMode = mode;
|
||||
updateButtonVisibility(mode);
|
||||
|
||||
switch (mode) {
|
||||
case NONE: presentModeNone(); break;
|
||||
case CROP: presentModeCrop(); break;
|
||||
case DRAW: presentModeDraw(); break;
|
||||
case BLUR: presentModeBlur(); break;
|
||||
case HIGHLIGHT: presentModeHighlight(); break;
|
||||
case TEXT: presentModeText(); break;
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
eventListener.onModeStarted(mode);
|
||||
}
|
||||
eventListener.onRequestFullScreen(mode != Mode.NONE, mode != Mode.TEXT);
|
||||
}
|
||||
|
||||
private void updateButtonVisibility(@NonNull Mode mode) {
|
||||
Set<View> visibleButtons = visibilityModeMap.get(mode);
|
||||
for (View button : allViews) {
|
||||
button.setVisibility(buttonIsVisible(visibleButtons, button) ? VISIBLE : GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean buttonIsVisible(@Nullable Set<View> visibleButtons, @NonNull View button) {
|
||||
return visibleButtons != null &&
|
||||
visibleButtons.contains(button) &&
|
||||
(button != undoButton || undoAvailable);
|
||||
}
|
||||
|
||||
private void presentModeNone() {
|
||||
blurToast.setVisibility(GONE);
|
||||
}
|
||||
|
||||
private void presentModeCrop() {
|
||||
updateCropAspectLockImage(eventListener.isCropAspectLocked());
|
||||
}
|
||||
|
||||
private void presentModeDraw() {
|
||||
colorPicker.setOnColorChangeListener(standardOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.RED);
|
||||
}
|
||||
|
||||
private void presentModeBlur() {
|
||||
colorPicker.setOnColorChangeListener(standardOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.BLACK);
|
||||
}
|
||||
|
||||
private void presentModeHighlight() {
|
||||
colorPicker.setOnColorChangeListener(highlightOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.YELLOW);
|
||||
}
|
||||
|
||||
private void presentModeText() {
|
||||
colorPicker.setOnColorChangeListener(standardOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.WHITE);
|
||||
}
|
||||
|
||||
private final VerticalSlideColorPicker.OnColorChangeListener standardOnColorChangeListener = selectedColor -> eventListener.onColorChange(selectedColor);
|
||||
|
||||
private final VerticalSlideColorPicker.OnColorChangeListener highlightOnColorChangeListener = selectedColor -> eventListener.onColorChange(withHighlighterAlpha(selectedColor));
|
||||
|
||||
private static int withHighlighterAlpha(int color) {
|
||||
return color & ~0xff000000 | 0x60000000;
|
||||
}
|
||||
|
||||
public void setUndoAvailability(boolean undoAvailable) {
|
||||
this.undoAvailable = undoAvailable;
|
||||
|
||||
undoButton.setVisibility(buttonIsVisible(visibilityModeMap.get(currentMode), undoButton) ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
NONE,
|
||||
CROP,
|
||||
TEXT,
|
||||
DRAW,
|
||||
HIGHLIGHT,
|
||||
BLUR,
|
||||
MOVE_DELETE,
|
||||
INSERT_STICKER,
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
void onModeStarted(@NonNull Mode mode);
|
||||
void onColorChange(int color);
|
||||
void onBlurFacesToggled(boolean enabled);
|
||||
void onUndo();
|
||||
void onDelete();
|
||||
void onSave();
|
||||
void onFlipHorizontal();
|
||||
void onRotate90AntiClockwise();
|
||||
void onCropAspectLock(boolean locked);
|
||||
boolean isCropAspectLocked();
|
||||
void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard);
|
||||
void onDone();
|
||||
}
|
||||
|
||||
private static final EventListener NULL_EVENT_LISTENER = new EventListener() {
|
||||
|
||||
@Override
|
||||
public void onModeStarted(@NonNull Mode mode) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorChange(int color) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBlurFacesToggled(boolean enabled) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUndo() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDelete() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSave() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFlipHorizontal() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRotate90AntiClockwise() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCropAspectLock(boolean locked) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCropAspectLocked() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDone() {
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package org.thoughtcrime.securesms.scribbles
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.SeekBar
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.appcompat.widget.AppCompatSeekBar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup
|
||||
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.getColor
|
||||
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setColor
|
||||
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.setListeners
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class ImageEditorHudV2 @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private var listener: EventListener? = null
|
||||
private var currentMode: Mode = Mode.NONE
|
||||
private var undoAvailability: Boolean = false
|
||||
private var isAvatarEdit: Boolean = false
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.v2_media_image_editor_hud, this)
|
||||
}
|
||||
|
||||
private val undoButton: View = findViewById(R.id.image_editor_hud_undo)
|
||||
private val clearAllButton: View = findViewById(R.id.image_editor_hud_clear_all)
|
||||
private val cancelButton: View = findViewById(R.id.image_editor_hud_cancel_button)
|
||||
private val drawButton: View = findViewById(R.id.image_editor_hud_draw_button)
|
||||
private val textButton: View = findViewById(R.id.image_editor_hud_text_button)
|
||||
private val stickerButton: View = findViewById(R.id.image_editor_hud_sticker_button)
|
||||
private val blurButton: View = findViewById(R.id.image_editor_hud_blur_button)
|
||||
private val doneButton: View = findViewById(R.id.image_editor_hud_done_button)
|
||||
private val drawSeekBar: AppCompatSeekBar = findViewById(R.id.image_editor_hud_draw_color_bar)
|
||||
private val brushToggle: ImageView = findViewById(R.id.image_editor_hud_draw_brush)
|
||||
private val widthSeekBar: AppCompatSeekBar = findViewById(R.id.image_editor_hud_draw_width_bar)
|
||||
private val cropRotateButton: View = findViewById(R.id.image_editor_hud_rotate_button)
|
||||
private val cropFlipButton: View = findViewById(R.id.image_editor_hud_flip_button)
|
||||
private val cropAspectLockButton: ImageView = findViewById(R.id.image_editor_hud_aspect_lock_button)
|
||||
private val blurToggleContainer: View = findViewById(R.id.image_editor_hud_blur_toggle_container)
|
||||
private val blurToggle: SwitchMaterial = findViewById(R.id.image_editor_hud_blur_toggle)
|
||||
private val blurToast: View = findViewById(R.id.image_editor_hud_blur_toast)
|
||||
private val blurHelpText: View = findViewById(R.id.image_editor_hud_blur_help_text)
|
||||
private val colorIndicator: ImageView = findViewById(R.id.image_editor_hud_color_indicator)
|
||||
|
||||
private val selectableSet: Set<View> = setOf(drawButton, textButton, stickerButton, blurButton)
|
||||
|
||||
private val undoTools: Set<View> = setOf(undoButton, clearAllButton)
|
||||
private val drawTools: Set<View> = setOf(brushToggle, drawSeekBar, widthSeekBar)
|
||||
private val blurTools: Set<View> = setOf(blurToggleContainer, blurHelpText, widthSeekBar)
|
||||
private val drawButtonRow: Set<View> = setOf(cancelButton, doneButton, drawButton, textButton, stickerButton, blurButton)
|
||||
private val cropButtonRow: Set<View> = setOf(cancelButton, doneButton, cropRotateButton, cropFlipButton, cropAspectLockButton)
|
||||
|
||||
private val viewsToSlide: Set<View> = drawButtonRow + cropButtonRow
|
||||
|
||||
private val modeChangeAnimationThrottler = ThrottledDebouncer(ANIMATION_DURATION)
|
||||
private val undoToolsAnimationThrottler = ThrottledDebouncer(ANIMATION_DURATION)
|
||||
|
||||
private val toastDebouncer = Debouncer(3000)
|
||||
private var colorIndicatorAlphaAnimator: Animator? = null
|
||||
|
||||
init {
|
||||
initializeViews()
|
||||
setMode(currentMode)
|
||||
}
|
||||
|
||||
private fun initializeViews() {
|
||||
undoButton.setOnClickListener { listener?.onUndo() }
|
||||
clearAllButton.setOnClickListener { listener?.onClearAll() }
|
||||
cancelButton.setOnClickListener { listener?.onCancel() }
|
||||
|
||||
drawButton.setOnClickListener { setMode(Mode.DRAW) }
|
||||
blurButton.setOnClickListener { setMode(Mode.BLUR) }
|
||||
textButton.setOnClickListener { setMode(Mode.TEXT) }
|
||||
stickerButton.setOnClickListener { setMode(Mode.INSERT_STICKER) }
|
||||
brushToggle.setOnClickListener {
|
||||
if (currentMode == Mode.DRAW) {
|
||||
setMode(Mode.HIGHLIGHT)
|
||||
} else {
|
||||
setMode(Mode.DRAW)
|
||||
}
|
||||
}
|
||||
|
||||
doneButton.setOnClickListener {
|
||||
if (isAvatarEdit && currentMode == Mode.CROP) {
|
||||
setMode(Mode.NONE)
|
||||
} else {
|
||||
listener?.onDone()
|
||||
}
|
||||
}
|
||||
|
||||
drawSeekBar.setUpForColor(
|
||||
thumbBorderColor = Color.WHITE,
|
||||
onColorChanged = {
|
||||
updateColorIndicator()
|
||||
listener?.onColorChange(getActiveColor())
|
||||
},
|
||||
onDragStart = {
|
||||
colorIndicatorAlphaAnimator?.end()
|
||||
colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 1f)
|
||||
colorIndicatorAlphaAnimator?.duration = 150L
|
||||
colorIndicatorAlphaAnimator?.start()
|
||||
},
|
||||
onDragEnd = {
|
||||
colorIndicatorAlphaAnimator?.end()
|
||||
colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 0f)
|
||||
colorIndicatorAlphaAnimator?.duration = 150L
|
||||
colorIndicatorAlphaAnimator?.start()
|
||||
}
|
||||
)
|
||||
|
||||
cropFlipButton.setOnClickListener { listener?.onFlipHorizontal() }
|
||||
cropRotateButton.setOnClickListener { listener?.onRotate90AntiClockwise() }
|
||||
|
||||
cropAspectLockButton.setOnClickListener {
|
||||
listener?.onCropAspectLock()
|
||||
if (listener?.isCropAspectLocked == true) {
|
||||
cropAspectLockButton.setImageResource(R.drawable.ic_crop_lock_24)
|
||||
} else {
|
||||
cropAspectLockButton.setImageResource(R.drawable.ic_crop_unlock_24)
|
||||
}
|
||||
}
|
||||
|
||||
blurToggle.setOnCheckedChangeListener { _, enabled -> listener?.onBlurFacesToggled(enabled) }
|
||||
|
||||
setupWidthSeekBar()
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun setupWidthSeekBar() {
|
||||
widthSeekBar.thumb = HSVColorSlider.createThumbDrawable(Color.WHITE)
|
||||
widthSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
listener?.onBrushWidthChange(progress)
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
|
||||
})
|
||||
|
||||
widthSeekBar.setOnTouchListener { v, event ->
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
v?.animate()
|
||||
?.setDuration(ANIMATION_DURATION)
|
||||
?.setInterpolator(DecelerateInterpolator())
|
||||
?.translationX(ViewUtil.dpToPx(36).toFloat())
|
||||
} else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
||||
v?.animate()
|
||||
?.setDuration(ANIMATION_DURATION)
|
||||
?.setInterpolator(DecelerateInterpolator())
|
||||
?.translationX(0f)
|
||||
}
|
||||
|
||||
v.onTouchEvent(event)
|
||||
}
|
||||
|
||||
widthSeekBar.progress = 20
|
||||
}
|
||||
|
||||
fun setUpForAvatarEditing() {
|
||||
isAvatarEdit = true
|
||||
}
|
||||
|
||||
fun setColorPalette(colors: Set<Int>) {
|
||||
}
|
||||
|
||||
fun getActiveColor(): Int {
|
||||
return if (currentMode == Mode.HIGHLIGHT) {
|
||||
withHighlighterAlpha(drawSeekBar.getColor())
|
||||
} else {
|
||||
drawSeekBar.getColor()
|
||||
}
|
||||
}
|
||||
|
||||
fun getColorIndex(): Int {
|
||||
return drawSeekBar.progress
|
||||
}
|
||||
|
||||
fun setColorIndex(index: Int) {
|
||||
drawSeekBar.progress = index
|
||||
}
|
||||
|
||||
fun setActiveColor(color: Int) {
|
||||
drawSeekBar.setColor(color or 0xFF000000.toInt())
|
||||
updateColorIndicator()
|
||||
}
|
||||
|
||||
fun getActiveBrushWidth(): Int {
|
||||
return widthSeekBar.progress
|
||||
}
|
||||
|
||||
fun setBlurFacesToggleEnabled(enabled: Boolean) {
|
||||
blurToggle.setOnCheckedChangeListener(null)
|
||||
blurToggle.isChecked = enabled
|
||||
blurToggle.setOnCheckedChangeListener { _, value -> listener?.onBlurFacesToggled(value) }
|
||||
}
|
||||
|
||||
fun showBlurHudTooltip() {
|
||||
TooltipPopup.forTarget(blurButton)
|
||||
.setText(R.string.ImageEditorHud_new_blur_faces_or_draw_anywhere_to_blur)
|
||||
.setBackgroundTint(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
.setTextColor(ContextCompat.getColor(context, R.color.core_white))
|
||||
.show(TooltipPopup.POSITION_BELOW)
|
||||
}
|
||||
|
||||
fun showBlurToast() {
|
||||
blurToast.clearAnimation()
|
||||
blurToast.visible = true
|
||||
toastDebouncer.publish { blurToast.visible = false }
|
||||
}
|
||||
|
||||
fun hideBlurToast() {
|
||||
blurToast.clearAnimation()
|
||||
blurToast.visible = false
|
||||
toastDebouncer.clear()
|
||||
}
|
||||
|
||||
fun setEventListener(eventListener: EventListener?) {
|
||||
listener = eventListener
|
||||
}
|
||||
|
||||
fun enterMode(mode: Mode) {
|
||||
setMode(mode, false)
|
||||
}
|
||||
|
||||
fun setMode(mode: Mode) {
|
||||
setMode(mode, true)
|
||||
}
|
||||
|
||||
fun getMode(): Mode = currentMode
|
||||
|
||||
fun setUndoAvailability(undoAvailability: Boolean) {
|
||||
this.undoAvailability = undoAvailability
|
||||
|
||||
if (currentMode != Mode.NONE) {
|
||||
if (undoAvailability) {
|
||||
animateInUndoTools()
|
||||
} else {
|
||||
animateOutUndoTools()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMode(mode: Mode, notify: Boolean) {
|
||||
val previousMode: Mode = currentMode
|
||||
currentMode = mode
|
||||
// updateVisibilities
|
||||
clearSelection()
|
||||
|
||||
when (mode) {
|
||||
Mode.NONE -> presentModeNone()
|
||||
Mode.CROP -> presentModeCrop()
|
||||
Mode.TEXT -> presentModeText()
|
||||
Mode.DRAW -> presentModeDraw()
|
||||
Mode.BLUR -> presentModeBlur()
|
||||
Mode.HIGHLIGHT -> presentModeHighlight()
|
||||
Mode.INSERT_STICKER -> presentModeMoveDelete()
|
||||
Mode.MOVE_DELETE -> presentModeMoveDelete()
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
listener?.onModeStarted(mode, previousMode)
|
||||
}
|
||||
|
||||
listener?.onRequestFullScreen(mode != Mode.NONE, mode != Mode.TEXT)
|
||||
}
|
||||
|
||||
private fun presentModeNone() {
|
||||
if (isAvatarEdit) {
|
||||
animateViewSetChange(
|
||||
inSet = drawButtonRow,
|
||||
outSet = cropButtonRow + blurTools + drawTools
|
||||
)
|
||||
animateInUndoTools()
|
||||
} else {
|
||||
animateViewSetChange(
|
||||
inSet = setOf(),
|
||||
outSet = drawButtonRow + cropButtonRow + blurTools + drawTools
|
||||
)
|
||||
animateOutUndoTools()
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentModeCrop() {
|
||||
animateViewSetChange(
|
||||
inSet = cropButtonRow - if (isAvatarEdit) setOf(cropAspectLockButton) else setOf(),
|
||||
outSet = drawButtonRow + blurTools + drawTools
|
||||
)
|
||||
animateInUndoTools()
|
||||
}
|
||||
|
||||
private fun presentModeDraw() {
|
||||
drawButton.isSelected = true
|
||||
brushToggle.setImageResource(R.drawable.ic_draw_white_24)
|
||||
listener?.onColorChange(getActiveColor())
|
||||
updateColorIndicator()
|
||||
animateViewSetChange(
|
||||
inSet = drawButtonRow + drawTools,
|
||||
outSet = cropButtonRow + blurTools
|
||||
)
|
||||
animateInUndoTools()
|
||||
}
|
||||
|
||||
private fun presentModeHighlight() {
|
||||
drawButton.isSelected = true
|
||||
brushToggle.setImageResource(R.drawable.ic_marker_24)
|
||||
listener?.onColorChange(getActiveColor())
|
||||
updateColorIndicator()
|
||||
animateViewSetChange(
|
||||
inSet = drawButtonRow + drawTools,
|
||||
outSet = cropButtonRow + blurTools
|
||||
)
|
||||
animateInUndoTools()
|
||||
}
|
||||
|
||||
private fun presentModeBlur() {
|
||||
blurButton.isSelected = true
|
||||
animateViewSetChange(
|
||||
inSet = drawButtonRow + blurTools,
|
||||
outSet = drawTools
|
||||
)
|
||||
animateInUndoTools()
|
||||
}
|
||||
|
||||
private fun presentModeText() {
|
||||
textButton.isSelected = true
|
||||
animateViewSetChange(
|
||||
inSet = setOf(drawSeekBar),
|
||||
outSet = drawTools + blurTools + drawButtonRow + cropButtonRow
|
||||
)
|
||||
animateOutUndoTools()
|
||||
}
|
||||
|
||||
private fun presentModeMoveDelete() {
|
||||
animateViewSetChange(
|
||||
outSet = drawTools + blurTools + drawButtonRow + cropButtonRow
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearSelection() {
|
||||
selectableSet.forEach { it.isSelected = false }
|
||||
}
|
||||
|
||||
private fun updateColorIndicator() {
|
||||
colorIndicator.drawable.colorFilter = SimpleColorFilter(drawSeekBar.getColor())
|
||||
colorIndicator.translationX = (drawSeekBar.thumb.bounds.left.toFloat() + ViewUtil.dpToPx(16))
|
||||
}
|
||||
|
||||
private fun animateViewSetChange(
|
||||
inSet: Set<View> = setOf(),
|
||||
outSet: Set<View> = setOf(),
|
||||
throttledDebouncer: ThrottledDebouncer = modeChangeAnimationThrottler
|
||||
) {
|
||||
val actualOutSet = outSet - inSet
|
||||
|
||||
throttledDebouncer.publish {
|
||||
animateInViewSet(inSet)
|
||||
animateOutViewSet(actualOutSet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateInViewSet(viewSet: Set<View>) {
|
||||
viewSet.forEach { view ->
|
||||
if (!view.isVisible) {
|
||||
view.animation = getInAnimation(view)
|
||||
view.animation.duration = ANIMATION_DURATION
|
||||
view.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateOutViewSet(viewSet: Set<View>) {
|
||||
viewSet.forEach { view ->
|
||||
if (view.isVisible) {
|
||||
val animation: Animation = getOutAnimation(view)
|
||||
animation.duration = ANIMATION_DURATION
|
||||
animation.setListeners(
|
||||
onAnimationEnd = {
|
||||
view.visibility = GONE
|
||||
}
|
||||
)
|
||||
|
||||
view.startAnimation(animation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInAnimation(view: View): Animation {
|
||||
return if (viewsToSlide.contains(view)) {
|
||||
AnimationUtils.loadAnimation(context, R.anim.slide_from_bottom)
|
||||
} else {
|
||||
AnimationUtils.loadAnimation(context, R.anim.fade_in)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOutAnimation(view: View): Animation {
|
||||
return if (viewsToSlide.contains(view)) {
|
||||
AnimationUtils.loadAnimation(context, R.anim.slide_to_bottom)
|
||||
} else {
|
||||
AnimationUtils.loadAnimation(context, R.anim.fade_out)
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateInUndoTools() {
|
||||
animateViewSetChange(
|
||||
inSet = undoToolsIfAvailable(),
|
||||
throttledDebouncer = undoToolsAnimationThrottler
|
||||
)
|
||||
}
|
||||
|
||||
private fun animateOutUndoTools() {
|
||||
animateViewSetChange(
|
||||
outSet = undoTools,
|
||||
throttledDebouncer = undoToolsAnimationThrottler
|
||||
)
|
||||
}
|
||||
|
||||
private fun undoToolsIfAvailable(): Set<View> {
|
||||
return if (undoAvailability) {
|
||||
undoTools
|
||||
} else {
|
||||
setOf()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
NONE,
|
||||
CROP,
|
||||
TEXT,
|
||||
DRAW,
|
||||
HIGHLIGHT,
|
||||
BLUR,
|
||||
MOVE_DELETE,
|
||||
INSERT_STICKER
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ANIMATION_DURATION = 250L
|
||||
|
||||
private fun withHighlighterAlpha(color: Int): Int {
|
||||
return color and 0xFF000000.toInt().inv() or 0x60000000
|
||||
}
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
fun onModeStarted(mode: Mode, previousMode: Mode)
|
||||
fun onColorChange(color: Int)
|
||||
fun onBrushWidthChange(@IntRange(from = 0, to = 100) widthPercentage: Int)
|
||||
fun onBlurFacesToggled(enabled: Boolean)
|
||||
fun onUndo()
|
||||
fun onClearAll()
|
||||
fun onDelete()
|
||||
fun onSave()
|
||||
fun onFlipHorizontal()
|
||||
fun onRotate90AntiClockwise()
|
||||
fun onCropAspectLock()
|
||||
val isCropAspectLocked: Boolean
|
||||
|
||||
fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean)
|
||||
fun onDone()
|
||||
fun onCancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.thoughtcrime.securesms.scribbles
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.widget.AppCompatSeekBar
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment
|
||||
import org.thoughtcrime.securesms.imageeditor.HiddenEditText
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.getColor
|
||||
import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
class TextEntryDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_image_editor_text_entry_fragment) {
|
||||
|
||||
private lateinit var hiddenTextEntry: HiddenEditText
|
||||
private lateinit var controller: Controller
|
||||
|
||||
private var colorIndicatorAlphaAnimator: Animator? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
controller = requireNotNull(findListener())
|
||||
|
||||
hiddenTextEntry = HiddenEditText(requireContext())
|
||||
(view as ViewGroup).addView(hiddenTextEntry)
|
||||
|
||||
view.setOnClickListener {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
val element: EditorElement = requireNotNull(requireArguments().getParcelable("element"))
|
||||
val incognito = requireArguments().getBoolean("incognito")
|
||||
val selectAll = requireArguments().getBoolean("selectAll")
|
||||
|
||||
hiddenTextEntry.setCurrentTextEditorElement(element)
|
||||
hiddenTextEntry.setIncognitoKeyboardEnabled(incognito)
|
||||
|
||||
if (selectAll) {
|
||||
hiddenTextEntry.selectAll()
|
||||
}
|
||||
|
||||
hiddenTextEntry.setOnEditOrSelectionChange { editorElement, textRenderer ->
|
||||
controller.zoomToFitText(editorElement, textRenderer)
|
||||
}
|
||||
|
||||
hiddenTextEntry.setOnEndEdit {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
ViewUtil.focusAndShowKeyboard(hiddenTextEntry)
|
||||
|
||||
val slider: AppCompatSeekBar = view.findViewById(R.id.image_editor_hud_draw_color_bar)
|
||||
val colorIndicator: ImageView = view.findViewById(R.id.image_editor_hud_color_indicator)
|
||||
slider.setUpForColor(
|
||||
Color.WHITE,
|
||||
{
|
||||
colorIndicator.drawable.colorFilter = SimpleColorFilter(slider.getColor())
|
||||
colorIndicator.translationX = (slider.thumb.bounds.left.toFloat() + ViewUtil.dpToPx(16))
|
||||
controller.onTextColorChange(slider.progress)
|
||||
},
|
||||
{
|
||||
colorIndicatorAlphaAnimator?.end()
|
||||
colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 1f)
|
||||
colorIndicatorAlphaAnimator?.duration = 150L
|
||||
colorIndicatorAlphaAnimator?.start()
|
||||
},
|
||||
{
|
||||
colorIndicatorAlphaAnimator?.end()
|
||||
colorIndicatorAlphaAnimator = ObjectAnimator.ofFloat(colorIndicator, "alpha", colorIndicator.alpha, 0f)
|
||||
colorIndicatorAlphaAnimator?.duration = 150L
|
||||
colorIndicatorAlphaAnimator?.start()
|
||||
}
|
||||
)
|
||||
|
||||
slider.progress = requireArguments().getInt("color_index")
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
controller.onTextEntryDialogDismissed(!hiddenTextEntry.text.isNullOrEmpty())
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
fun onTextEntryDialogDismissed(hasText: Boolean)
|
||||
fun zoomToFitText(editorElement: EditorElement, textRenderer: MultiLineTextRenderer)
|
||||
fun onTextColorChange(colorIndex: Int)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun show(
|
||||
fragmentManager: FragmentManager,
|
||||
editorElement: EditorElement,
|
||||
isIncognitoEnabled: Boolean,
|
||||
selectAll: Boolean,
|
||||
colorIndex: Int
|
||||
) {
|
||||
val args = Bundle().apply {
|
||||
putParcelable("element", editorElement)
|
||||
putBoolean("incognito", isIncognitoEnabled)
|
||||
putBoolean("selectAll", selectAll)
|
||||
putInt("color_index", colorIndex)
|
||||
}
|
||||
|
||||
TextEntryDialogFragment().apply {
|
||||
arguments = args
|
||||
show(fragmentManager, "text_entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
@@ -51,15 +52,16 @@ public final class UriGlideRenderer implements Renderer {
|
||||
public static final float WEAK_BLUR = 3f;
|
||||
public static final float STRONG_BLUR = 25f;
|
||||
|
||||
private final Uri imageUri;
|
||||
private final Paint paint = new Paint();
|
||||
private final Matrix imageProjectionMatrix = new Matrix();
|
||||
private final Matrix temp = new Matrix();
|
||||
private final Matrix blurScaleMatrix = new Matrix();
|
||||
private final boolean decryptable;
|
||||
private final int maxWidth;
|
||||
private final int maxHeight;
|
||||
private final float blurRadius;
|
||||
private final Uri imageUri;
|
||||
private final Paint paint = new Paint();
|
||||
private final Matrix imageProjectionMatrix = new Matrix();
|
||||
private final Matrix temp = new Matrix();
|
||||
private final Matrix blurScaleMatrix = new Matrix();
|
||||
private final boolean decryptable;
|
||||
private final int maxWidth;
|
||||
private final int maxHeight;
|
||||
private final float blurRadius;
|
||||
private final RequestListener<Bitmap> bitmapRequestListener;
|
||||
|
||||
@Nullable private Bitmap bitmap;
|
||||
@Nullable private Bitmap blurredBitmap;
|
||||
@@ -70,11 +72,16 @@ public final class UriGlideRenderer implements Renderer {
|
||||
}
|
||||
|
||||
public UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight, float blurRadius) {
|
||||
this.imageUri = imageUri;
|
||||
this.decryptable = decryptable;
|
||||
this.maxWidth = maxWidth;
|
||||
this.maxHeight = maxHeight;
|
||||
this.blurRadius = blurRadius;
|
||||
this(imageUri, decryptable, maxWidth, maxHeight, blurRadius, null);
|
||||
}
|
||||
|
||||
public UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight, float blurRadius, @Nullable RequestListener<Bitmap> bitmapRequestListener) {
|
||||
this.imageUri = imageUri;
|
||||
this.decryptable = decryptable;
|
||||
this.maxWidth = maxWidth;
|
||||
this.maxHeight = maxHeight;
|
||||
this.blurRadius = blurRadius;
|
||||
this.bitmapRequestListener = bitmapRequestListener;
|
||||
paint.setAntiAlias(true);
|
||||
paint.setFilterBitmap(true);
|
||||
paint.setDither(true);
|
||||
@@ -186,6 +193,7 @@ public final class UriGlideRenderer implements Renderer {
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.override(width, height)
|
||||
.centerInside()
|
||||
.addListener(bitmapRequestListener)
|
||||
.load(decryptable ? new DecryptableStreamUriLoader.DecryptableUri(imageUri) : imageUri);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles.widget;
|
||||
|
||||
import android.graphics.PorterDuff;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public class ColorPaletteAdapter extends RecyclerView.Adapter<ColorPaletteAdapter.ColorViewHolder> {
|
||||
|
||||
private final List<Integer> colors = new ArrayList<>();
|
||||
|
||||
private EventListener eventListener;
|
||||
|
||||
@Override
|
||||
public @NonNull ColorViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
return new ColorViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_color, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ColorViewHolder holder, int position) {
|
||||
holder.bind(colors.get(position), eventListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return colors.size();
|
||||
}
|
||||
|
||||
public void setColors(@NonNull Collection<Integer> colors) {
|
||||
this.colors.clear();
|
||||
this.colors.addAll(colors);
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setEventListener(@Nullable EventListener eventListener) {
|
||||
this.eventListener = eventListener;
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
void onColorSelected(int color);
|
||||
}
|
||||
|
||||
static class ColorViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
ImageView foreground;
|
||||
|
||||
ColorViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
foreground = itemView.findViewById(R.id.palette_item_foreground);
|
||||
}
|
||||
|
||||
void bind(int color, @Nullable EventListener eventListener) {
|
||||
foreground.setColorFilter(color, PorterDuff.Mode.SRC_IN);
|
||||
|
||||
if (eventListener != null) {
|
||||
itemView.setOnClickListener(v -> eventListener.onColorSelected(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 Mark Charles
|
||||
* Copyright (c) 2016 Open Whisper Systems
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.scribbles.widget;
|
||||
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.LinearGradient;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Shader;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class VerticalSlideColorPicker extends View {
|
||||
|
||||
private static final float INDICATOR_TO_BAR_WIDTH_RATIO = 0.5f;
|
||||
|
||||
private Paint paint;
|
||||
private Paint strokePaint;
|
||||
private Paint indicatorStrokePaint;
|
||||
private Paint indicatorFillPaint;
|
||||
private Path path;
|
||||
private Bitmap bitmap;
|
||||
private Canvas bitmapCanvas;
|
||||
|
||||
private int viewWidth;
|
||||
private int viewHeight;
|
||||
private int centerX;
|
||||
private float colorPickerRadius;
|
||||
private RectF colorPickerBody;
|
||||
|
||||
private OnColorChangeListener onColorChangeListener;
|
||||
|
||||
private int borderColor;
|
||||
private float borderWidth;
|
||||
private float indicatorRadius;
|
||||
private int[] colors;
|
||||
|
||||
private int touchY;
|
||||
private int activeColor;
|
||||
|
||||
public VerticalSlideColorPicker(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public VerticalSlideColorPicker(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.VerticalSlideColorPicker, 0, 0);
|
||||
|
||||
try {
|
||||
int colorsResourceId = a.getResourceId(R.styleable.VerticalSlideColorPicker_pickerColors, R.array.scribble_colors);
|
||||
|
||||
colors = a.getResources().getIntArray(colorsResourceId);
|
||||
borderColor = a.getColor(R.styleable.VerticalSlideColorPicker_pickerBorderColor, Color.WHITE);
|
||||
borderWidth = a.getDimension(R.styleable.VerticalSlideColorPicker_pickerBorderWidth, 10f);
|
||||
|
||||
} finally {
|
||||
a.recycle();
|
||||
}
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
public VerticalSlideColorPicker(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public VerticalSlideColorPicker(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
setWillNotDraw(false);
|
||||
|
||||
paint = new Paint();
|
||||
paint.setStyle(Paint.Style.FILL);
|
||||
paint.setAntiAlias(true);
|
||||
|
||||
path = new Path();
|
||||
|
||||
strokePaint = new Paint();
|
||||
strokePaint.setStyle(Paint.Style.STROKE);
|
||||
strokePaint.setColor(borderColor);
|
||||
strokePaint.setAntiAlias(true);
|
||||
strokePaint.setStrokeWidth(borderWidth);
|
||||
|
||||
indicatorStrokePaint = new Paint(strokePaint);
|
||||
indicatorStrokePaint.setStrokeWidth(borderWidth / 2);
|
||||
|
||||
indicatorFillPaint = new Paint();
|
||||
indicatorFillPaint.setStyle(Paint.Style.FILL);
|
||||
indicatorFillPaint.setAntiAlias(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
|
||||
path.addCircle(centerX, borderWidth + colorPickerRadius + indicatorRadius, colorPickerRadius, Path.Direction.CW);
|
||||
path.addRect(colorPickerBody, Path.Direction.CW);
|
||||
path.addCircle(centerX, viewHeight - (borderWidth + colorPickerRadius + indicatorRadius), colorPickerRadius, Path.Direction.CW);
|
||||
|
||||
bitmapCanvas.drawColor(Color.TRANSPARENT);
|
||||
|
||||
bitmapCanvas.drawPath(path, strokePaint);
|
||||
bitmapCanvas.drawPath(path, paint);
|
||||
|
||||
canvas.drawBitmap(bitmap, 0, 0, null);
|
||||
|
||||
touchY = Math.max((int) colorPickerBody.top, touchY);
|
||||
|
||||
indicatorFillPaint.setColor(activeColor);
|
||||
canvas.drawCircle(centerX, touchY, indicatorRadius, indicatorFillPaint);
|
||||
canvas.drawCircle(centerX, touchY, indicatorRadius, indicatorStrokePaint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
touchY = (int) Math.min(event.getY(), colorPickerBody.bottom);
|
||||
touchY = (int) Math.max(colorPickerBody.top, touchY);
|
||||
|
||||
activeColor = bitmap.getPixel(viewWidth/2, touchY);
|
||||
|
||||
if (onColorChangeListener != null) {
|
||||
onColorChangeListener.onColorChange(activeColor);
|
||||
}
|
||||
|
||||
invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
|
||||
viewWidth = w;
|
||||
viewHeight = h;
|
||||
|
||||
if (viewWidth <= 0 || viewHeight <= 0) return;
|
||||
|
||||
int barWidth = (int) (viewWidth * INDICATOR_TO_BAR_WIDTH_RATIO);
|
||||
|
||||
centerX = viewWidth / 2;
|
||||
indicatorRadius = (viewWidth / 2) - borderWidth;
|
||||
colorPickerRadius = (barWidth / 2) - borderWidth;
|
||||
|
||||
colorPickerBody = new RectF(centerX - colorPickerRadius,
|
||||
borderWidth + colorPickerRadius + indicatorRadius,
|
||||
centerX + colorPickerRadius,
|
||||
viewHeight - (borderWidth + colorPickerRadius + indicatorRadius));
|
||||
|
||||
LinearGradient gradient = new LinearGradient(0, colorPickerBody.top, 0, colorPickerBody.bottom, colors, null, Shader.TileMode.CLAMP);
|
||||
paint.setShader(gradient);
|
||||
|
||||
if (bitmap != null) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
|
||||
bitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
|
||||
bitmapCanvas = new Canvas(bitmap);
|
||||
}
|
||||
|
||||
public void setBorderColor(int borderColor) {
|
||||
this.borderColor = borderColor;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setBorderWidth(float borderWidth) {
|
||||
this.borderWidth = borderWidth;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setColors(int[] colors) {
|
||||
this.colors = colors;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setActiveColor(int color) {
|
||||
activeColor = color;
|
||||
|
||||
if (colorPickerBody != null) {
|
||||
touchY = (int) colorPickerBody.top;
|
||||
}
|
||||
|
||||
if (onColorChangeListener != null) {
|
||||
onColorChangeListener.onColorChange(color);
|
||||
}
|
||||
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public int getActiveColor() {
|
||||
return activeColor;
|
||||
}
|
||||
|
||||
public void setOnColorChangeListener(OnColorChangeListener onColorChangeListener) {
|
||||
this.onColorChangeListener = onColorChangeListener;
|
||||
}
|
||||
|
||||
public interface OnColorChangeListener {
|
||||
void onColorChange(int selectedColor);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity;
|
||||
@@ -672,12 +672,12 @@ public class ShareActivity extends PassphraseRequiredActivity
|
||||
Optional.absent()));
|
||||
}
|
||||
|
||||
startActivityForResult(MediaSendActivity.buildShareIntent(this,
|
||||
media,
|
||||
Stream.of(multiShareArgs.getShareContactAndThreads()).map(ShareContactAndThread::getRecipientId).toList(),
|
||||
multiShareArgs.getDraftText(),
|
||||
MultiShareSender.getWorstTransportOption(this, multiShareArgs.getShareContactAndThreads())),
|
||||
RESULT_MEDIA_CONFIRMATION);
|
||||
Intent intent = MediaSelectionActivity.share(this,
|
||||
MultiShareSender.getWorstTransportOption(this, multiShareArgs.getShareContactAndThreads()),
|
||||
media,
|
||||
Stream.of(multiShareArgs.getShareContactAndThreads()).map(ShareContactAndThread::getRecipientId).toList(),
|
||||
multiShareArgs.getDraftText());
|
||||
startActivityForResult(intent, RESULT_MEDIA_CONFIRMATION);
|
||||
break;
|
||||
default:
|
||||
//noinspection CodeBlock2Expr
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.view.animation.Animation
|
||||
|
||||
fun Animation.setListeners(
|
||||
onAnimationStart: (animation: Animation?) -> Unit = { },
|
||||
onAnimationEnd: (animation: Animation?) -> Unit = { },
|
||||
onAnimationRepeat: (animation: Animation?) -> Unit = { }
|
||||
) {
|
||||
this.setAnimationListener(object : Animation.AnimationListener {
|
||||
override fun onAnimationStart(animation: Animation?) {
|
||||
onAnimationStart(animation)
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
onAnimationEnd(animation)
|
||||
}
|
||||
|
||||
override fun onAnimationRepeat(animation: Animation?) {
|
||||
onAnimationRepeat(animation)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.DocumentSlide;
|
||||
@@ -283,6 +284,10 @@ public class MediaUtil {
|
||||
return (null != contentType) && contentType.startsWith("text/");
|
||||
}
|
||||
|
||||
public static boolean isNonGifVideo(Media media) {
|
||||
return isVideo(media.getMimeType()) && !media.isVideoGif();
|
||||
}
|
||||
|
||||
public static boolean isImageType(String contentType) {
|
||||
if (contentType == null) {
|
||||
return false;
|
||||
|
||||
@@ -27,6 +27,8 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
|
||||
@@ -113,6 +115,11 @@ public class VideoPlayer extends FrameLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(PlaybackException error) {
|
||||
playerCallback.onError();
|
||||
}
|
||||
});
|
||||
exoView.setPlayer(exoPlayer);
|
||||
exoControls.setPlayer(exoPlayer);
|
||||
@@ -321,5 +328,7 @@ public class VideoPlayer extends FrameLayout {
|
||||
void onPlaying();
|
||||
|
||||
void onStopped();
|
||||
|
||||
void onError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,11 @@ import androidx.appcompat.app.AppCompatDelegate;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaFolder;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaPickerFolderFragment;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaPickerItemFragment;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
public final class WallpaperImageSelectionActivity extends AppCompatActivity
|
||||
implements MediaPickerFolderFragment.Controller,
|
||||
MediaPickerItemFragment.Controller
|
||||
implements MediaGalleryFragment.Callbacks
|
||||
{
|
||||
private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID";
|
||||
private static final int CROP = 901;
|
||||
@@ -46,23 +43,10 @@ public final class WallpaperImageSelectionActivity extends AppCompatActivity
|
||||
setContentView(R.layout.wallpaper_image_selection_activity);
|
||||
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, MediaPickerFolderFragment.newInstance(getString(R.string.WallpaperImageSelectionActivity__choose_wallpaper_image), true))
|
||||
.replace(R.id.fragment_container, new MediaGalleryFragment())
|
||||
.commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFolderSelected(@NonNull MediaFolder folder) {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), 1, false, true))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraSelected() {
|
||||
throw new AssertionError("Unexpected, Camera disabled");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaSelected(@NonNull Media media) {
|
||||
startActivityForResult(WallpaperCropActivity.newIntent(this, getRecipientId(), media.getUri()), CROP);
|
||||
@@ -80,4 +64,34 @@ public final class WallpaperImageSelectionActivity extends AppCompatActivity
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMultiselectEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaUnselected(@NonNull Media media) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectedMediaClicked(@NonNull Media media) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNavigateToCamera() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSubmit() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolbarNavigationClicked() {
|
||||
// TODO [alex]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user