Refresh media selection and sending flow with a shiny new UX.

This commit is contained in:
Alex Hart
2021-09-02 17:04:43 -03:00
committed by Greyson Parrelli
parent a940487611
commit 664d6475d9
195 changed files with 7075 additions and 4812 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ public final class CompositeMediaTransform implements MediaTransform {
private final MediaTransform[] transforms;
CompositeMediaTransform(MediaTransform ...transforms) {
public CompositeMediaTransform(MediaTransform ...transforms) {
this.transforms = transforms;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import org.thoughtcrime.securesms.mediasend.Media
data class MediaCaptureState(
val mostRecentMedia: Media? = null
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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