From 240b2108f311cd6c65976ad4bac1d0991cc6c5f9 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 2 Mar 2020 11:21:57 -0400 Subject: [PATCH] Use the image editor for avatars. --- app/build.gradle | 1 - app/src/main/AndroidManifest.xml | 4 + .../securesms/GroupCreateActivity.java | 51 ++-- .../securesms/avatar/AvatarSelection.java | 149 ------------ .../model/EditorElementHierarchy.java | 43 ++-- .../imageeditor/model/EditorModel.java | 62 +++-- .../renderers/CropAreaRenderer.java | 38 +-- .../renderers/OvalGuideRenderer.java | 86 +++++++ .../mediasend/AvatarSelectionActivity.java | 223 ++++++++++++++++++ ...tarSelectionBottomSheetDialogFragment.java | 199 ++++++++++++++++ .../securesms/mediasend/CameraFragment.java | 12 +- .../securesms/mediasend/CameraXFragment.java | 28 ++- .../mediasend/MediaPickerItemFragment.java | 36 +-- .../mediasend/MediaSendActivity.java | 4 + .../profiles/edit/EditProfileFragment.java | 99 ++++---- .../scribbles/ImageEditorFragment.java | 25 +- .../securesms/scribbles/ImageEditorHud.java | 23 +- .../res/layout/avatar_selection_activity.xml | 5 + ...selection_bottom_sheet_dialog_fragment.xml | 21 ++ ...on_bottom_sheet_dialog_fragment_option.xml | 12 + app/src/main/res/layout/image_editor_hud.xml | 19 +- app/src/main/res/values/attrs.xml | 4 + .../main/res/values/crop_area_renderer.xml | 2 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/values/themes.xml | 8 + app/witness-verifications.gradle | 3 - 26 files changed, 850 insertions(+), 313 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java create mode 100644 app/src/main/res/layout/avatar_selection_activity.xml create mode 100644 app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment.xml create mode 100644 app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment_option.xml diff --git a/app/build.gradle b/app/build.gradle index ec178a0a11..2196d42819 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -133,7 +133,6 @@ dependencies { implementation 'com.pnikosis:materialish-progress:1.5' implementation 'org.greenrobot:eventbus:3.0.0' implementation 'pl.tajchert:waitingdots:0.1.0' - implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0' implementation 'com.melnykov:floatingactionbutton:1.3.0' implementation 'com.google.zxing:android-integration:3.1.0' implementation 'mobi.upod:time-duration-picker:1.1.3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41fed89ecc..cbe4d43f46 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -404,6 +404,10 @@ android:theme="@style/TextSecure.LightNoActionBar" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java b/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java index 712ad2b0f2..bd5f68dba4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java @@ -21,17 +21,9 @@ import android.app.Activity; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; -import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; import android.text.TextUtils; - -import org.thoughtcrime.securesms.avatar.AvatarSelection; -import org.thoughtcrime.securesms.conversation.ConversationActivity; -import org.thoughtcrime.securesms.logging.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -42,6 +34,10 @@ import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.request.target.SimpleTarget; import com.bumptech.glide.request.transition.Transition; @@ -52,6 +48,7 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; import org.thoughtcrime.securesms.contacts.RecipientsEditor; import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; @@ -59,6 +56,11 @@ import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.GroupManager.GroupActionResult; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -73,7 +75,6 @@ import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.util.InvalidNumberException; -import java.io.File; import java.util.Collection; import java.util.HashSet; import java.util.LinkedList; @@ -98,8 +99,9 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity private final DynamicTheme dynamicTheme = new DynamicTheme(); private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); - private static final int PICK_CONTACT = 1; - public static final int AVATAR_SIZE = 210; + private static final short REQUEST_CODE_SELECT_AVATAR = 26165; + private static final int PICK_CONTACT = 1; + public static final int AVATAR_SIZE = 210; private EditText groupName; private ListView lv; @@ -197,8 +199,12 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity recipientsEditor.setHint(R.string.recipients_panel__add_members); recipientsPanel.setPanelChangeListener(this); findViewById(R.id.contacts_button).setOnClickListener(new AddRecipientButtonListener()); - avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_group_outline_34, R.drawable.ic_group_outline_20).asDrawable(this, ContactColors.UNKNOWN_COLOR.toConversationColor(this))); - avatar.setOnClickListener(view -> AvatarSelection.startAvatarSelection(this, false, false)); + avatar.setImageDrawable(getDefaultGroupAvatar()); + avatar.setOnClickListener(view -> AvatarSelectionBottomSheetDialogFragment.create(avatarBmp != null, false, REQUEST_CODE_SELECT_AVATAR).show(getSupportFragmentManager(), null)); + } + + private Drawable getDefaultGroupAvatar() { + return new ResourceContactPhoto(R.drawable.ic_group_outline_34, R.drawable.ic_group_outline_20).asDrawable(this, ContactColors.UNKNOWN_COLOR.toConversationColor(this)); } private void initializeExistingGroup() { @@ -284,7 +290,6 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity @Override public void onActivityResult(int reqCode, int resultCode, final Intent data) { super.onActivityResult(reqCode, resultCode, data); - Uri outputFile = Uri.fromFile(new File(getCacheDir(), "cropped")); if (data == null || resultCode != Activity.RESULT_OK) return; @@ -299,15 +304,19 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } break; + case REQUEST_CODE_SELECT_AVATAR: + if (data.getBooleanExtra("delete", false)) { + avatarBmp = null; + avatar.setImageDrawable(getDefaultGroupAvatar()); + return; + } + + final Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + final DecryptableUri decryptableUri = new DecryptableUri(result.getUri()); - case AvatarSelection.REQUEST_CODE_AVATAR: - AvatarSelection.circularCropImage(this, data.getData(), outputFile, R.string.CropImageActivity_group_avatar); - break; - case AvatarSelection.REQUEST_CODE_CROP_IMAGE: - final Uri resultUri = AvatarSelection.getResultUri(data); GlideApp.with(this) .asBitmap() - .load(resultUri) + .load(decryptableUri) .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) .centerCrop() @@ -315,7 +324,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity .into(new SimpleTarget() { @Override public void onResourceReady(@NonNull Bitmap resource, Transition transition) { - setAvatar(resultUri, resource); + setAvatar(decryptableUri, resource); } }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.java b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.java deleted file mode 100644 index 952aa3d9af..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.java +++ /dev/null @@ -1,149 +0,0 @@ -package org.thoughtcrime.securesms.avatar; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.provider.MediaStore; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; - -import com.theartofdev.edmodo.cropper.CropImage; -import com.theartofdev.edmodo.cropper.CropImageView; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.util.FileProviderUtil; -import org.thoughtcrime.securesms.util.IntentUtils; - -import java.io.File; -import java.io.IOException; -import java.util.LinkedList; -import java.util.List; - -import static android.provider.MediaStore.EXTRA_OUTPUT; - -public final class AvatarSelection { - - private static final String TAG = AvatarSelection.class.getSimpleName(); - - private AvatarSelection() { - } - - public static final int REQUEST_CODE_CROP_IMAGE = CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE; - public static final int REQUEST_CODE_AVATAR = REQUEST_CODE_CROP_IMAGE + 1; - - /** - * Returns result on {@link #REQUEST_CODE_CROP_IMAGE} - */ - public static void circularCropImage(Activity activity, Uri inputFile, Uri outputFile, @StringRes int title) { - CropImage.activity(inputFile) - .setGuidelines(CropImageView.Guidelines.ON) - .setAspectRatio(1, 1) - .setCropShape(CropImageView.CropShape.OVAL) - .setOutputUri(outputFile) - .setAllowRotation(true) - .setAllowFlipping(true) - .setBackgroundColor(ContextCompat.getColor(activity, R.color.avatar_background)) - .setActivityTitle(activity.getString(title)) - .start(activity); - } - - /** - * Returns result on {@link #REQUEST_CODE_CROP_IMAGE} - */ - public static void circularCropImage(Fragment fragment, Uri inputFile, Uri outputFile, @StringRes int title) { - CropImage.activity(inputFile) - .setGuidelines(CropImageView.Guidelines.ON) - .setAspectRatio(1, 1) - .setCropShape(CropImageView.CropShape.OVAL) - .setOutputUri(outputFile) - .setAllowRotation(true) - .setAllowFlipping(true) - .setBackgroundColor(ContextCompat.getColor(fragment.requireContext(), R.color.avatar_background)) - .setActivityTitle(fragment.requireContext().getString(title)) - .start(fragment.requireContext(), fragment); - } - - public static Uri getResultUri(Intent data) { - return CropImage.getActivityResult(data).getUri(); - } - - /** - * Returns result on {@link #REQUEST_CODE_AVATAR} - * - * @return Temporary capture file if created. - */ - public static File startAvatarSelection(Activity activity, boolean includeClear, boolean attemptToIncludeCamera) { - File captureFile = attemptToIncludeCamera ? getCaptureFile(activity) : null; - - Intent chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear); - activity.startActivityForResult(chooserIntent, REQUEST_CODE_AVATAR); - return captureFile; - } - - /** - * Returns result on {@link #REQUEST_CODE_AVATAR} - * - * @return Temporary capture file if created. - */ - public static File startAvatarSelection(Fragment fragment, boolean includeClear, boolean attemptToIncludeCamera) { - File captureFile = attemptToIncludeCamera ? getCaptureFile(fragment.requireContext()) : null; - - Intent chooserIntent = createAvatarSelectionIntent(fragment.requireContext(), captureFile, includeClear); - fragment.startActivityForResult(chooserIntent, REQUEST_CODE_AVATAR); - return captureFile; - } - - private static @Nullable File getCaptureFile(@NonNull Context context) { - if (!Permissions.hasAll(context, Manifest.permission.CAMERA)) { - return null; - } - - try { - return File.createTempFile("capture", "jpg", context.getExternalCacheDir()); - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - private static Intent createAvatarSelectionIntent(Context context, @Nullable File tempCaptureFile, boolean includeClear) { - List extraIntents = new LinkedList<>(); - Intent galleryIntent = new Intent(Intent.ACTION_PICK); - - galleryIntent.setDataAndType(android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); - - if (!IntentUtils.isResolvable(context, galleryIntent)) { - galleryIntent = new Intent(Intent.ACTION_GET_CONTENT); - galleryIntent.setType("image/*"); - } - - if (tempCaptureFile != null) { - Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - - if (cameraIntent.resolveActivity(context.getPackageManager()) != null) { - cameraIntent.putExtra(EXTRA_OUTPUT, FileProviderUtil.getUriFor(context, tempCaptureFile)); - extraIntents.add(cameraIntent); - } - } - - if (includeClear) { - extraIntents.add(new Intent("org.thoughtcrime.securesms.action.CLEAR_PROFILE_PHOTO")); - } - - Intent chooserIntent = Intent.createChooser(galleryIntent, context.getString(R.string.CreateProfileActivity_profile_photo)); - - if (!extraIntents.isEmpty()) { - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, extraIntents.toArray(new Intent[0])); - } - - return chooserIntent; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java index 10b9c918b8..cab52e3f48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java @@ -4,6 +4,7 @@ import android.graphics.Matrix; import android.graphics.Point; import android.graphics.PointF; import android.graphics.RectF; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.imageeditor.Bounds; import org.thoughtcrime.securesms.imageeditor.renderers.CropAreaRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.InverseFillRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.OvalGuideRenderer; /** * Creates and handles a strict EditorElement Hierarchy. @@ -43,15 +45,15 @@ import org.thoughtcrime.securesms.imageeditor.renderers.InverseFillRenderer; final class EditorElementHierarchy { static @NonNull EditorElementHierarchy create() { - return new EditorElementHierarchy(createRoot()); + return new EditorElementHierarchy(createRoot(false)); } - static @NonNull EditorElementHierarchy create(@Nullable EditorElement root) { - if (root == null) { - return create(); - } else { - return new EditorElementHierarchy(root); - } + static @NonNull EditorElementHierarchy createForCircleEditing() { + return new EditorElementHierarchy(createRoot(true)); + } + + static @NonNull EditorElementHierarchy create(@NonNull EditorElement root) { + return new EditorElementHierarchy(root); } private final EditorElement root; @@ -76,7 +78,7 @@ final class EditorElementHierarchy { this.thumbs = this.cropEditorElement.getChild(1); } - private static @NonNull EditorElement createRoot() { + private static @NonNull EditorElement createRoot(boolean circleEdit) { EditorElement root = new EditorElement(null); EditorElement imageRoot = new EditorElement(null); @@ -94,7 +96,7 @@ final class EditorElementHierarchy { EditorElement imageCrop = new EditorElement(null); overlay.addElement(imageCrop); - EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color)); + EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color, !circleEdit)); cropEditorElement.getFlags() .setRotateLocked(true) @@ -114,11 +116,20 @@ final class EditorElementHierarchy { cropEditorElement.addElement(blackout); - cropEditorElement.addElement(createThumbs(cropEditorElement)); + cropEditorElement.addElement(createThumbs(cropEditorElement, !circleEdit)); + + if (circleEdit) { + EditorElement circle = new EditorElement(new OvalGuideRenderer(R.color.crop_circle_guide_color)); + circle.getFlags().setSelectable(false) + .persist(); + + cropEditorElement.addElement(circle); + } + return root; } - private static @NonNull EditorElement createThumbs(EditorElement cropEditorElement) { + private static @NonNull EditorElement createThumbs(EditorElement cropEditorElement, boolean centerThumbs) { EditorElement thumbs = new EditorElement(null); thumbs.getFlags() @@ -127,11 +138,13 @@ final class EditorElementHierarchy { .setVisible(false) .persist(); - thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_LEFT)); - thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_RIGHT)); + if (centerThumbs) { + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_RIGHT)); - thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_CENTER)); - thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_CENTER)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_CENTER)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_CENTER)); + } thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_LEFT)); thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_RIGHT)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java index d9e8256515..a102642d9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -41,7 +41,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { private static final int MINIMUM_OUTPUT_WIDTH = 1024; private static final int MINIMUM_CROP_PIXEL_COUNT = 100; - private static final Point MINIMIM_RATIO = new Point(15, 1); + private static final Point MINIMUM_RATIO = new Point(15, 1); @NonNull private Runnable invalidate = NULL_RUNNABLE; @@ -50,26 +50,44 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { private final UndoRedoStacks undoRedoStacks; private final UndoRedoStacks cropUndoRedoStacks; - private final InBoundsMemory inBoundsMemory = new InBoundsMemory(); + private final InBoundsMemory inBoundsMemory = new InBoundsMemory(); private EditorElementHierarchy editorElementHierarchy; - private final RectF visibleViewPort = new RectF(); - private final Point size; + private final RectF visibleViewPort = new RectF(); + private final Point size; + private final boolean circleEditing; public EditorModel() { + this(false, EditorElementHierarchy.create()); + } + + private EditorModel(@NonNull Parcel in) { + ClassLoader classLoader = getClass().getClassLoader(); + this.circleEditing = in.readByte() == 1; + this.size = new Point(in.readInt(), in.readInt()); + //noinspection ConstantConditions + this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader)); + this.undoRedoStacks = in.readParcelable(classLoader); + this.cropUndoRedoStacks = in.readParcelable(classLoader); + } + + public EditorModel(boolean circleEditing, @NonNull EditorElementHierarchy editorElementHierarchy) { + this.circleEditing = circleEditing; this.size = new Point(1024, 1024); - this.editorElementHierarchy = EditorElementHierarchy.create(); + this.editorElementHierarchy = editorElementHierarchy; this.undoRedoStacks = new UndoRedoStacks(50); this.cropUndoRedoStacks = new UndoRedoStacks(50); } - private EditorModel(Parcel in) { - ClassLoader classLoader = getClass().getClassLoader(); - this.size = new Point(in.readInt(), in.readInt()); - this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader)); - this.undoRedoStacks = in.readParcelable(classLoader); - this.cropUndoRedoStacks = in.readParcelable(classLoader); + public static EditorModel create() { + return new EditorModel(false, EditorElementHierarchy.create()); + } + + public static EditorModel createForCircleEditing() { + EditorModel editorModel = new EditorModel(true, EditorElementHierarchy.createForCircleEditing()); + editorModel.setCropAspectLock(true); + return editorModel; } public void setInvalidate(@Nullable Runnable invalidate) { @@ -427,7 +445,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { int outputPixelCount = outputSize.x * outputSize.y; int minimumPixelCount = Math.min(size.x * size.y, MINIMUM_CROP_PIXEL_COUNT); - Point thinnestRatio = MINIMIM_RATIO; + Point thinnestRatio = MINIMUM_RATIO; if (compareRatios(size, thinnestRatio) < 0) { // original is narrower than the thinnestRatio @@ -514,6 +532,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { @Override public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (circleEditing ? 1 : 0)); dest.writeInt(size.x); dest.writeInt(size.y); dest.writeParcelable(editorElementHierarchy.getRoot(), flags); @@ -574,15 +593,30 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { @Override public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) { if (cropMatrix != null && size != null && isRendererOfMainImage(renderer)) { - boolean changedBefore = isChanged(); - Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix(); + boolean changedBefore = isChanged(); + Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix(); this.size.set(size.x, size.y); if (imageCropMatrix.isIdentity()) { imageCropMatrix.set(cropMatrix); + + if (circleEditing) { + Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix(); + if (size.x > size.y) { + userCropMatrix.setScale(size.y / (float) size.x, 1f); + } else { + userCropMatrix.setScale(1f, size.x / (float) size.y); + } + } + editorElementHierarchy.doneCrop(visibleViewPort, null); + if (!changedBefore) { undoRedoStacks.clear(editorElementHierarchy.getRoot()); } + + if (circleEditing) { + startCrop(); + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java index 65ddc1063b..a397e06e46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java @@ -6,6 +6,7 @@ import android.graphics.Paint; import android.graphics.Path; import android.graphics.RectF; import android.os.Parcel; + import androidx.annotation.ColorRes; import androidx.annotation.NonNull; import androidx.core.content.res.ResourcesCompat; @@ -25,7 +26,8 @@ import org.thoughtcrime.securesms.imageeditor.RendererContext; public final class CropAreaRenderer implements Renderer { @ColorRes - private final int color; + private final int color; + private final boolean renderCenterThumbs; private final Path cropClipPath = new Path(); private final Path screenClipPath = new Path(); @@ -66,31 +68,33 @@ public final class CropAreaRenderer implements Renderer { canvas.drawRect(-thickness, -thickness, size, size, paint); canvas.translate(0, halfDy); - canvas.drawRect(-thickness, -thickness, size, size, paint); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); canvas.translate(0, halfDy); canvas.drawRect(-thickness, -thickness, size, size, paint); canvas.translate(halfDx, 0); - canvas.drawRect(-thickness, -thickness, size, size, paint); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); canvas.translate(halfDx, 0); canvas.drawRect(-thickness, -thickness, size, size, paint); canvas.translate(0, -halfDy); - canvas.drawRect(-thickness, -thickness, size, size, paint); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); canvas.translate(0, -halfDy); canvas.drawRect(-thickness, -thickness, size, size, paint); canvas.translate(-halfDx, 0); - canvas.drawRect(-thickness, -thickness, size, size, paint); + if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint); rendererContext.restore(); } - public CropAreaRenderer(@ColorRes int color) { - this.color = color; + public CropAreaRenderer(@ColorRes int color, boolean renderCenterThumbs) { + this.color = color; + this.renderCenterThumbs = renderCenterThumbs; + cropClipPath.toggleInverseFillType(); cropClipPath.moveTo(Bounds.LEFT, Bounds.TOP); cropClipPath.lineTo(Bounds.RIGHT, Bounds.TOP); @@ -100,10 +104,6 @@ public final class CropAreaRenderer implements Renderer { screenClipPath.toggleInverseFillType(); } - private CropAreaRenderer(Parcel in) { - this(in.readInt()); - } - @Override public boolean hitTest(float x, float y) { return !Bounds.contains(x, y); @@ -111,23 +111,25 @@ public final class CropAreaRenderer implements Renderer { public static final Creator CREATOR = new Creator() { @Override - public CropAreaRenderer createFromParcel(Parcel in) { - return new CropAreaRenderer(in); + public @NonNull CropAreaRenderer createFromParcel(@NonNull Parcel in) { + return new CropAreaRenderer(in.readInt(), + in.readByte() == 1); } @Override - public CropAreaRenderer[] newArray(int size) { + public @NonNull CropAreaRenderer[] newArray(int size) { return new CropAreaRenderer[size]; } }; @Override - public int describeContents() { - return 0; + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + dest.writeByte((byte) (renderCenterThumbs ? 1 : 0)); } @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeInt(color); + public int describeContents() { + return 0; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java new file mode 100644 index 0000000000..643e838824 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/OvalGuideRenderer.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Parcel; + +import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders an oval inside of the {@link Bounds}. + *

+ * Hit tests outside of the bounds. + */ +public final class OvalGuideRenderer implements Renderer { + + private final @ColorRes int ovalGuideColor; + + private final Paint paint; + + private final RectF dst = new RectF(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.save(); + + Canvas canvas = rendererContext.canvas; + Context context = rendererContext.context; + int stroke = context.getResources().getDimensionPixelSize(R.dimen.oval_guide_stroke_width); + float halfStroke = stroke / 2f; + + this.paint.setStrokeWidth(stroke); + paint.setColor(ContextCompat.getColor(context, ovalGuideColor)); + + rendererContext.mapRect(dst, Bounds.FULL_BOUNDS); + dst.set(dst.left + halfStroke, dst.top + halfStroke, dst.right - halfStroke, dst.bottom - halfStroke); + + rendererContext.canvasMatrix.setToIdentity(); + canvas.drawOval(dst, paint); + + rendererContext.restore(); + } + + public OvalGuideRenderer(@ColorRes int color) { + this.ovalGuideColor = color; + + this.paint = new Paint(); + this.paint.setStyle(Paint.Style.STROKE); + this.paint.setAntiAlias(true); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public @NonNull OvalGuideRenderer createFromParcel(@NonNull Parcel in) { + return new OvalGuideRenderer(in.readInt()); + } + + @Override + public @NonNull OvalGuideRenderer[] newArray(int size) { + return new OvalGuideRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(ovalGuideColor); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java new file mode 100644 index 0000000000..09fa733a69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java @@ -0,0 +1,223 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; +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 { + + private static final String IMAGE_CAPTURE = "IMAGE_CAPTURE"; + private static final String IMAGE_EDITOR = "IMAGE_EDITOR"; + private static final String ARG_GALLERY = "ARG_GALLERY"; + + public static final String EXTRA_MEDIA = "avatar.media"; + + private Media currentMedia; + + public static Intent getIntentForCameraCapture(@NonNull Context context) { + return new Intent(context, AvatarSelectionActivity.class); + } + + public static Intent getIntentForGallery(@NonNull Context context) { + Intent intent = getIntentForCameraCapture(context); + + intent.putExtra(ARG_GALLERY, true); + + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + 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(); + } + } + + @Override + public void onCameraError() { + Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show(); + finish(); + } + + @Override + public void onImageCaptured(@NonNull byte[] data, int width, int height) { + Uri blobUri = BlobProvider.getInstance() + .forData(data) + .withMimeType(MediaUtil.IMAGE_JPEG) + .createForSingleSessionInMemory(); + + onMediaSelected(new Media(blobUri, + MediaUtil.IMAGE_JPEG, + System.currentTimeMillis(), + width, + height, + data.length, + 0, + Optional.of(Media.ALL_MEDIA_BUCKET_ID), + Optional.absent(), + Optional.absent())); + } + + @Override + public void onVideoCaptured(@NonNull FileDescriptor fd) { + throw new UnsupportedOperationException("Cannot set profile as video"); + } + + @Override + public void onVideoCaptureError() { + throw new AssertionError("This should never happen"); + } + + @Override + public void onGalleryClicked() { + if (isGalleryFirst() && popToRoot()) { + return; + } + + MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(this, null); + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, fragment); + + if (isCameraFirst()) { + transaction.addToBackStack(null); + } + + transaction.commit(); + } + + @Override + public int getDisplayRotation() { + return getWindowManager().getDefaultDisplay().getRotation(); + } + + @Override + public void onCameraCountButtonClicked() { + throw new UnsupportedOperationException("Cannot select more than one photo"); + } + + @Override + public void onTouchEventsNeeded(boolean needed) { + } + + @Override + 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; + + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, ImageEditorFragment.newInstanceForAvatar(media.getUri()), IMAGE_EDITOR) + .addToBackStack(IMAGE_EDITOR) + .commit(); + } + + @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(); + } + + @Override + public void onDoneEditing() { + handleSave(); + } + + public boolean popToRoot() { + final int backStackCount = getSupportFragmentManager().getBackStackEntryCount(); + if (backStackCount == 0) { + return false; + } + + for (int i = 0; i < backStackCount; i++) { + getSupportFragmentManager().popBackStack(); + } + + return true; + } + + private boolean isGalleryFirst() { + return getIntent().getBooleanExtra(ARG_GALLERY, false); + } + + private boolean isCameraFirst() { + return !isGalleryFirst(); + } + + private void handleSave() { + ImageEditorFragment fragment = (ImageEditorFragment) getSupportFragmentManager().findFragmentByTag(IMAGE_EDITOR); + if (fragment == null) { + throw new AssertionError(); + } + + ImageEditorFragment.Data data = (ImageEditorFragment.Data) fragment.saveState(); + if (data == null) { + throw new AssertionError(); + } + + EditorModel model = data.readModel(); + if (model == null) { + throw new AssertionError(); + } + + MediaRepository.transformMedia(this, + Collections.singletonList(currentMedia), + Collections.singletonMap(currentMedia, new ImageEditorModelRenderMediaTransform(model)), + output -> { + Media transformed = output.get(currentMedia); + + Intent result = new Intent(); + result.putExtra(EXTRA_MEDIA, transformed); + setResult(RESULT_OK, result); + finish(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java new file mode 100644 index 0000000000..7e3fedb6e5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java @@ -0,0 +1,199 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.util.Consumer; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.ArrayList; +import java.util.List; + +public class AvatarSelectionBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String ARG_OPTIONS = "options"; + private static final String ARG_REQUEST_CODE = "request_code"; + + public static DialogFragment create(boolean includeClear, boolean includeCamera, short resultCode) { + DialogFragment fragment = new AvatarSelectionBottomSheetDialogFragment(); + List selectionOptions = new ArrayList<>(3); + Bundle args = new Bundle(); + + if (includeCamera) { + selectionOptions.add(SelectionOption.CAPTURE); + } + + selectionOptions.add(SelectionOption.GALLERY); + + if (includeClear) { + selectionOptions.add(SelectionOption.DELETE); + } + + String[] options = Stream.of(selectionOptions) + .map(SelectionOption::getCode) + .toArray(String[]::new); + + args.putStringArray(ARG_OPTIONS, options); + args.putShort(ARG_REQUEST_CODE, resultCode); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Design_BottomSheetDialog_Fixed + : R.style.Theme_Design_Light_BottomSheetDialog_Fixed); + + super.onCreate(savedInstanceState); + + if (getOptionsCount() == 1) { + launchOptionAndDismiss(getOptionsFromArguments().get(0)); + } + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.avatar_selection_bottom_sheet_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + RecyclerView recyclerView = view.findViewById(R.id.avatar_selection_bottom_sheet_dialog_fragment_recycler); + recyclerView.setAdapter(new SelectionOptionAdapter(getOptionsFromArguments(), this::launchOptionAndDismiss)); + } + + @SuppressWarnings("ConstantConditions") + private int getOptionsCount() { + return requireArguments().getStringArray(ARG_OPTIONS).length; + } + + @SuppressWarnings("ConstantConditions") + private List getOptionsFromArguments() { + String[] optionCodes = requireArguments().getStringArray(ARG_OPTIONS); + + return Stream.of(optionCodes).map(SelectionOption::fromCode).toList(); + } + + private void launchOptionAndDismiss(@NonNull SelectionOption option) { + Intent intent = createIntent(requireContext(), option); + + int requestCode = requireArguments().getShort(ARG_REQUEST_CODE); + if (getParentFragment() != null) { + requireParentFragment().startActivityForResult(intent, requestCode); + } else { + requireActivity().startActivityForResult(intent, requestCode); + } + + dismiss(); + } + + private static Intent createIntent(@NonNull Context context, @NonNull SelectionOption selectionOption) { + switch (selectionOption) { + case CAPTURE: + return AvatarSelectionActivity.getIntentForCameraCapture(context); + case GALLERY: + return AvatarSelectionActivity.getIntentForGallery(context); + case DELETE: + return new Intent("org.thoughtcrime.securesms.action.CLEAR_PROFILE_PHOTO"); + default: + throw new IllegalStateException("Unknown option: " + selectionOption); + } + } + + private enum SelectionOption { + CAPTURE("capture", R.string.AvatarSelectionBottomSheetDialogFragment__take_photo, R.attr.avatar_selection_take_photo), + GALLERY("gallery", R.string.AvatarSelectionBottomSheetDialogFragment__choose_from_gallery, R.attr.avatar_selection_pick_photo), + DELETE("delete", R.string.AvatarSelectionBottomSheetDialogFragment__remove_photo, R.attr.avatar_selection_remove_photo); + + private final String code; + private final @StringRes int label; + private final @AttrRes int icon; + + SelectionOption(@NonNull String code, @StringRes int label, @AttrRes int icon) { + this.code = code; + this.label = label; + this.icon = icon; + } + + public @NonNull String getCode() { + return code; + } + + static SelectionOption fromCode(@NonNull String code) { + for (SelectionOption option : values()) { + if (option.code.equals(code)) { + return option; + } + } + + throw new IllegalStateException("Unknown option: " + code); + } + } + + private static class SelectionOptionViewHolder extends RecyclerView.ViewHolder { + + private final AppCompatTextView optionView; + + SelectionOptionViewHolder(@NonNull View itemView, @NonNull Consumer onClick) { + super(itemView); + itemView.setOnClickListener(v -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + onClick.accept(getAdapterPosition()); + } + }); + + optionView = (AppCompatTextView) itemView; + } + + void bind(@NonNull SelectionOption selectionOption) { + optionView.setCompoundDrawablesWithIntrinsicBounds(ThemeUtil.getThemedDrawable(optionView.getContext(), selectionOption.icon), null, null, null); + optionView.setText(selectionOption.label); + } + } + + private static class SelectionOptionAdapter extends RecyclerView.Adapter { + + private final List options; + private final Consumer onOptionClicked; + + private SelectionOptionAdapter(@NonNull List options, @NonNull Consumer onOptionClicked) { + this.options = options; + this.onOptionClicked = onOptionClicked; + } + + @NonNull + @Override + public SelectionOptionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.avatar_selection_bottom_sheet_dialog_fragment_option, parent, false); + return new SelectionOptionViewHolder(view, (position) -> onOptionClicked.accept(options.get(position))); + } + + @Override + public void onBindViewHolder(@NonNull SelectionOptionViewHolder holder, int position) { + holder.bind(options.get(position)); + } + + @Override + public int getItemCount() { + return options.size(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java index afd384ac7e..439f061f3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.mediasend; import android.annotation.SuppressLint; -import android.os.Build; import androidx.annotation.NonNull; import androidx.camera.core.CameraX; @@ -10,8 +9,6 @@ import androidx.fragment.app.Fragment; import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; import java.io.FileDescriptor; -import java.util.HashSet; -import java.util.Set; public interface CameraFragment { @@ -24,6 +21,15 @@ public interface CameraFragment { } } + @SuppressLint("RestrictedApi") + static Fragment newInstanceForAvatarCapture() { + if (CameraXUtil.isSupported() && CameraX.isInitialized()) { + return CameraXFragment.newInstanceForAvatarCapture(); + } else { + return Camera1Fragment.newInstance(); + } + } + interface Controller { void onCameraError(); void onImageCaptured(@NonNull byte[] data, int width, int height); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index 68f961efeb..8ea39dad91 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -25,7 +25,6 @@ import androidx.annotation.RequiresApi; import androidx.camera.core.CameraX; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageProxy; -import androidx.camera.core.impl.utils.executor.CameraXExecutors; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProviders; @@ -45,7 +44,6 @@ import org.thoughtcrime.securesms.util.MemoryFileDescriptor; import org.thoughtcrime.securesms.util.Stopwatch; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ThemeUtil; -import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.thoughtcrime.securesms.video.VideoUtil; import org.whispersystems.libsignal.util.guava.Optional; @@ -60,7 +58,8 @@ import java.io.IOException; @RequiresApi(21) public class CameraXFragment extends Fragment implements CameraFragment { - private static final String TAG = Log.tag(CameraXFragment.class); + private static final String TAG = Log.tag(CameraXFragment.class); + private static final String IS_VIDEO_ENABLED = "is_video_enabled"; private CameraXView camera; private ViewGroup controlsContainer; @@ -69,8 +68,22 @@ public class CameraXFragment extends Fragment implements CameraFragment { private View selfieFlash; private MemoryFileDescriptor videoFileDescriptor; + public static CameraXFragment newInstanceForAvatarCapture() { + CameraXFragment fragment = new CameraXFragment(); + Bundle args = new Bundle(); + + args.putBoolean(IS_VIDEO_ENABLED, false); + fragment.setArguments(args); + + return fragment; + } + public static CameraXFragment newInstance() { - return new CameraXFragment(); + CameraXFragment fragment = new CameraXFragment(); + + fragment.setArguments(new Bundle()); + + return fragment; } @Override @@ -282,9 +295,10 @@ public class CameraXFragment extends Fragment implements CameraFragment { } private boolean isVideoRecordingSupported(@NonNull Context context) { - return Build.VERSION.SDK_INT >= 26 && - MediaConstraints.isVideoTranscodeAvailable() && - CameraXUtil.isMixedModeSupported(context) && + return Build.VERSION.SDK_INT >= 26 && + requireArguments().getBoolean(IS_VIDEO_ENABLED, true) && + MediaConstraints.isVideoTranscodeAvailable() && + CameraXUtil.isMixedModeSupported(context) && VideoUtil.getMaxVideoDurationInSeconds(context, viewModel.getMediaConstraints()) > 0; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 09a026643a..59e86a2cc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -1,25 +1,25 @@ package org.thoughtcrime.securesms.mediasend; -import androidx.lifecycle.ViewModelProviders; import android.content.Context; import android.content.res.Configuration; import android.graphics.Point; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.appcompat.widget.Toolbar; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.view.WindowManager; 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 org.thoughtcrime.securesms.util.Util; @@ -32,9 +32,10 @@ import java.util.List; */ 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_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 String bucketId; private String folderTitle; @@ -45,10 +46,15 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem 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) { 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); MediaPickerItemFragment fragment = new MediaPickerItemFragment(); fragment.setArguments(args); @@ -110,8 +116,10 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem public void onResume() { super.onResume(); viewModel.onItemPickerStarted(); - adapter.setForcedMultiSelect(true); - viewModel.onMultiSelectStarted(); + if (requireArguments().getBoolean(KEY_FORCE_MULTI_SELECT)) { + adapter.setForcedMultiSelect(true); + viewModel.onMultiSelectStarted(); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 29dd638270..d04e93588c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -470,6 +470,10 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple } } + @Override + public void onDoneEditing() { + } + @Override public void onGlobalLayout() { hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java index 8edec4a81d..a51e15a248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -2,10 +2,8 @@ package org.thoughtcrime.securesms.profiles.edit; import android.Manifest; import android.animation.Animator; -import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.Editable; @@ -31,28 +29,31 @@ import androidx.navigation.Navigation; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.dd.CircularProgressButton; +import com.google.android.gms.common.util.IOUtils; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.avatar.AvatarSelection; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.lock.v2.PinUtil; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.megaphone.Megaphones; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints; import org.thoughtcrime.securesms.profiles.ProfileName; -import org.thoughtcrime.securesms.util.BitmapDecodingException; -import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.thoughtcrime.securesms.util.text.AfterTextChanged; import org.whispersystems.libsignal.util.guava.Optional; -import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import static android.app.Activity.RESULT_OK; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.DISPLAY_USERNAME; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.EXCLUDE_SYSTEM; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_BUTTON_TEXT; @@ -61,8 +62,9 @@ import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.SHOW_ public class EditProfileFragment extends Fragment { - private static final String TAG = Log.tag(EditProfileFragment.class); - private static final String AVATAR_STATE = "avatar"; + private static final String TAG = Log.tag(EditProfileFragment.class); + private static final String AVATAR_STATE = "avatar"; + private static final short REQUEST_CODE_SELECT_AVATAR = 31726; private Toolbar toolbar; private View title; @@ -77,7 +79,6 @@ public class EditProfileFragment extends Fragment { private TextView username; private Intent nextIntent; - private File captureFile; private EditProfileViewModel viewModel; @@ -151,52 +152,38 @@ public class EditProfileFragment extends Fragment { public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - switch (requestCode) { - case AvatarSelection.REQUEST_CODE_AVATAR: - if (resultCode == Activity.RESULT_OK) { - Uri outputFile = Uri.fromFile(new File(requireActivity().getCacheDir(), "cropped")); - Uri inputFile = (data != null ? data.getData() : null); + if (requestCode == REQUEST_CODE_SELECT_AVATAR && resultCode == RESULT_OK) { - if (inputFile == null && captureFile != null) { - inputFile = Uri.fromFile(captureFile); - } + if (data != null && data.getBooleanExtra("delete", false)) { + viewModel.setAvatar(null); + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), getResources().getColor(R.color.grey_400))); + return; + } - if (data != null && data.getBooleanExtra("delete", false)) { - viewModel.setAvatar(null); - avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), getResources().getColor(R.color.grey_400))); - } else { - AvatarSelection.circularCropImage(this, inputFile, outputFile, R.string.CropImageActivity_profile_avatar); - } + SimpleTask.run(() -> { + try { + Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + InputStream stream = BlobProvider.getInstance().getStream(requireContext(), result.getUri()); + + return IOUtils.readInputStreamFully(stream); + } catch (IOException ioException) { + Log.w(TAG, ioException); + return null; } - - break; - case AvatarSelection.REQUEST_CODE_CROP_IMAGE: - if (resultCode == Activity.RESULT_OK) { - SimpleTask.run(() -> { - try { - BitmapUtil.ScaleResult result = BitmapUtil.createScaledBytes(requireActivity(), AvatarSelection.getResultUri(data), new ProfileMediaConstraints()); - return result.getBitmap(); - } catch (BitmapDecodingException e) { - Log.w(TAG, e); - return null; - } - }, - (avatarBytes) -> { - if (avatarBytes != null) { - viewModel.setAvatar(avatarBytes); - GlideApp.with(EditProfileFragment.this) - .load(avatarBytes) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .circleCrop() - .into(avatar); - } else { - Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show(); - } - } - ); + }, + (avatarBytes) -> { + if (avatarBytes != null) { + viewModel.setAvatar(avatarBytes); + GlideApp.with(EditProfileFragment.this) + .load(avatarBytes) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .circleCrop() + .into(avatar); + } else { + Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show(); } - break; + }); } } @@ -314,18 +301,12 @@ public class EditProfileFragment extends Fragment { } private void startAvatarSelection() { - captureFile = AvatarSelection.startAvatarSelection(this, viewModel.hasAvatar(), true); + AvatarSelectionBottomSheetDialogFragment.create(viewModel.hasAvatar(), true, REQUEST_CODE_SELECT_AVATAR).show(getChildFragmentManager(), null); } private void handleUpload() { viewModel.submitProfile(uploadResult -> { if (uploadResult == EditProfileRepository.UploadResult.SUCCESS) { - if (captureFile != null) { - if (!captureFile.delete()) { - Log.w(TAG, "Failed to delete capture file " + captureFile); - } - } - if (!PinUtil.shouldShowPinCreationDuringRegistration(requireContext())) { SignalStore.registrationValues().setRegistrationComplete(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 0a98afd522..92cc62f44a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -47,7 +47,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private static final String TAG = Log.tag(ImageEditorFragment.class); - private static final String KEY_IMAGE_URI = "image_uri"; + private static final String KEY_IMAGE_URI = "image_uri"; + private static final String KEY_IS_AVATAR_MODE = "avatar_mode"; private static final int SELECT_OLD_STICKER_REQUEST_CODE = 123; private static final int SELECT_NEW_STICKER_REQUEST_CODE = 124; @@ -89,6 +90,12 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private ImageEditorHud imageEditorHud; private ImageEditorView imageEditorView; + public static ImageEditorFragment newInstanceForAvatar(@NonNull Uri imageUri) { + ImageEditorFragment fragment = newInstance(imageUri); + fragment.requireArguments().putBoolean(KEY_IS_AVATAR_MODE, true); + return fragment; + } + public static ImageEditorFragment newInstance(@NonNull Uri imageUri) { Bundle args = new Bundle(); args.putParcelable(KEY_IMAGE_URI, imageUri); @@ -133,6 +140,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + boolean isAvatarMode = requireArguments().getBoolean(KEY_IS_AVATAR_MODE, false); + imageEditorHud = view.findViewById(R.id.scribble_hud); imageEditorView = view.findViewById(R.id.image_editor_view); @@ -150,12 +159,17 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } if (editorModel == null) { - editorModel = new EditorModel(); + editorModel = isAvatarMode ? EditorModel.createForCircleEditing() : EditorModel.create(); EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight)); image.getFlags().setSelectable(false).persist(); editorModel.addElement(image); } + if (isAvatarMode) { + imageEditorHud.setUpForAvatarEditing(); + imageEditorHud.enterMode(ImageEditorHud.Mode.CROP); + } + imageEditorView.setModel(editorModel); refreshUniqueColors(); @@ -381,6 +395,11 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu controller.onRequestFullScreen(fullScreen, hideKeyboard); } + @Override + public void onDone() { + controller.onDoneEditing(); + } + private void refreshUniqueColors() { imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha()); } @@ -439,5 +458,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu void onTouchEventsNeeded(boolean needed); void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard); + + void onDoneEditing(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java index 31340eead4..a928d895cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java @@ -7,7 +7,6 @@ import android.view.View; import android.widget.ImageView; import android.widget.LinearLayout; -import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; @@ -41,6 +40,7 @@ public final class ImageEditorHud extends LinearLayout { private View saveButton; private View deleteButton; private View confirmButton; + private View doneButton; private VerticalSlideColorPicker colorPicker; private RecyclerView colorPalette; @@ -88,6 +88,7 @@ public final class ImageEditorHud extends LinearLayout { 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); cropAspectLock.setOnClickListener(v -> { eventListener.onCropAspectLock(!eventListener.isCropAspectLocked()); @@ -123,6 +124,7 @@ public final class ImageEditorHud extends LinearLayout { } allViews.add(stickerButton); + allViews.add(doneButton); } private void setVisibleViewsWhenInMode(Mode mode, View... views) { @@ -154,6 +156,20 @@ public final class ImageEditorHud extends LinearLayout { textButton.setOnClickListener(v -> setMode(Mode.TEXT)); stickerButton.setOnClickListener(v -> setMode(Mode.INSERT_STICKER)); saveButton.setOnClickListener(v -> eventListener.onSave()); + doneButton.setOnClickListener(v -> eventListener.onDone()); + } + + 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 colors) { @@ -266,6 +282,7 @@ public final class ImageEditorHud extends LinearLayout { void onCropAspectLock(boolean locked); boolean isCropAspectLocked(); void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard); + void onDone(); } private static final EventListener NULL_EVENT_LISTENER = new EventListener() { @@ -310,5 +327,9 @@ public final class ImageEditorHud extends LinearLayout { @Override public void onRequestFullScreen(boolean fullScreen, boolean hideKeyboard) { } + + @Override + public void onDone() { + } }; } diff --git a/app/src/main/res/layout/avatar_selection_activity.xml b/app/src/main/res/layout/avatar_selection_activity.xml new file mode 100644 index 0000000000..1d249e3e93 --- /dev/null +++ b/app/src/main/res/layout/avatar_selection_activity.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment.xml new file mode 100644 index 0000000000..abf1a1e261 --- /dev/null +++ b/app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment_option.xml b/app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment_option.xml new file mode 100644 index 0000000000..06933bed4e --- /dev/null +++ b/app/src/main/res/layout/avatar_selection_bottom_sheet_dialog_fragment_option.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/image_editor_hud.xml b/app/src/main/res/layout/image_editor_hud.xml index 5c4f442be2..7428388af1 100644 --- a/app/src/main/res/layout/image_editor_hud.xml +++ b/app/src/main/res/layout/image_editor_hud.xml @@ -1,13 +1,12 @@ - + tools:background="@color/core_grey_60" + tools:parentTag="android.widget.LinearLayout"> @@ -57,7 +56,7 @@ android:background="?attr/selectableItemBackgroundBorderless" android:padding="8dp" android:src="@drawable/ic_text_32" /> - + + + + + + + diff --git a/app/src/main/res/values/crop_area_renderer.xml b/app/src/main/res/values/crop_area_renderer.xml index 953c9c04ca..b21eda8fee 100644 --- a/app/src/main/res/values/crop_area_renderer.xml +++ b/app/src/main/res/values/crop_area_renderer.xml @@ -3,8 +3,10 @@ 32dp 2dp + 1dp #ffffffff #7f000000 + #66FFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f794de8034..cd78befb46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,12 @@ Using default: %s None + + Choose photo + Take photo + Choose from gallery + Remove photo + Now %dm diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 42e3cc664f..2382a4da6e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -368,6 +368,10 @@ @color/core_grey_90 @color/core_grey_60 + @drawable/ic_camera_outline_24 + @drawable/ic_photo_outline_24 + @drawable/ic_trash_outline_24 + @drawable/ic_audio_light @drawable/ic_video_light @@ -631,6 +635,10 @@ @color/core_grey_05 @color/core_grey_25 + @drawable/ic_camera_solid_24 + @drawable/ic_photo_solid_24 + @drawable/ic_trash_solid_24 + @drawable/ic_audio_dark @drawable/ic_video_dark diff --git a/app/witness-verifications.gradle b/app/witness-verifications.gradle index bd034da0e2..a16e99cfb2 100644 --- a/app/witness-verifications.gradle +++ b/app/witness-verifications.gradle @@ -321,9 +321,6 @@ dependencyVerification { ['com.takisoft.fix:colorpicker:0.9.1', 'f5d0dbabe406a1800498ca9c1faf34db36e021d8488bf10360f29961fe3ab0d1'], - ['com.theartofdev.edmodo:android-image-cropper:2.8.0', - '5516ea87672e478b3d0ed5c274a7df27d22c02e66f899388f9b8bee93669e176'], - ['com.tomergoldst.android:tooltips:1.0.6', '4c56697dd1ad64b8066535c61f961a6d901e7ae5d97ae27084ba40ad620349b6'],