Use the image editor for avatars.

master
Alex Hart 2020-03-02 11:21:57 -04:00 committed by GitHub
parent f68d99d16d
commit 240b2108f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 850 additions and 313 deletions

View File

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

View File

@ -404,6 +404,10 @@
android:theme="@style/TextSecure.LightNoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediasend.AvatarSelectionActivity"
android:theme="@style/TextSecure.FullScreenMedia"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".BlockedContactsActivity"
android:theme="@style/TextSecure.LightTheme"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

View File

@ -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<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, Transition<? super Bitmap> transition) {
setAvatar(resultUri, resource);
setAvatar(decryptableUri, resource);
}
});
}

View File

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

View File

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

View File

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

View File

@ -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<CropAreaRenderer> CREATOR = new Creator<CropAreaRenderer>() {
@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;
}
}

View File

@ -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}.
* <p>
* 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<OvalGuideRenderer> CREATOR = new Creator<OvalGuideRenderer>() {
@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);
}
}

View File

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

View File

@ -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<SelectionOption> 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<SelectionOption> 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<Integer> 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<SelectionOptionViewHolder> {
private final List<SelectionOption> options;
private final Consumer<SelectionOption> onOptionClicked;
private SelectionOptionAdapter(@NonNull List<SelectionOption> options, @NonNull Consumer<SelectionOption> 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();
}
}
}

View File

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

View File

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

View File

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

View File

@ -470,6 +470,10 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
}
}
@Override
public void onDoneEditing() {
}
@Override
public void onGlobalLayout() {
hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
style="@style/TextAppearance.AppCompat.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/AvatarSelectionBottomSheetDialogFragment__choose_photo" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/avatar_selection_bottom_sheet_dialog_fragment_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.AppCompatTextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/avatar_selection_bottom_sheet_dialog_fragment_recycler"
android:layout_width="match_parent"
android:layout_height="?listPreferredItemHeight"
android:drawablePadding="16dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textSize="16sp"
app:drawableTint="?icon_tint" />

View File

@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:parentTag="android.widget.LinearLayout"
tools:background="@color/core_grey_60">
tools:background="@color/core_grey_60"
tools:parentTag="android.widget.LinearLayout">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
@ -21,8 +20,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="10dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
@ -57,7 +56,7 @@
android:background="?attr/selectableItemBackgroundBorderless"
android:padding="8dp"
android:src="@drawable/ic_text_32" />
<ImageView
android:id="@+id/scribble_draw_button"
android:layout_width="wrap_content"
@ -98,6 +97,14 @@
android:padding="8dp"
android:src="@drawable/ic_check_circle_32" />
<ImageView
android:id="@+id/scribble_done_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:padding="8dp"
app:srcCompat="@drawable/ic_check_circle_32" />
</LinearLayout>
<org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker

View File

@ -31,6 +31,10 @@
<attr name="conversation_list_compose_icon_tint" format="color" />
<attr name="conversation_list_camera_button_background" format="color"/>
<attr name="avatar_selection_take_photo" format="reference" />
<attr name="avatar_selection_pick_photo" format="reference" />
<attr name="avatar_selection_remove_photo" format="reference" />
<attr name="kbs_splash_image" format="reference" />
<attr name="conversation_sent_card_background" format="reference|color"/>

View File

@ -3,8 +3,10 @@
<dimen name="crop_area_renderer_edge_size">32dp</dimen>
<dimen name="crop_area_renderer_edge_thickness">2dp</dimen>
<dimen name="oval_guide_stroke_width">1dp</dimen>
<color name="crop_area_renderer_edge_color">#ffffffff</color>
<color name="crop_area_renderer_outer_color">#7f000000</color>
<color name="crop_circle_guide_color">#66FFFFFF</color>
</resources>

View File

@ -355,6 +355,12 @@
<string name="CustomDefaultPreference_using_default">Using default: %s</string>
<string name="CustomDefaultPreference_none">None</string>
<!-- AvatarSelectionBottomSheetDialogFragment -->
<string name="AvatarSelectionBottomSheetDialogFragment__choose_photo">Choose photo</string>
<string name="AvatarSelectionBottomSheetDialogFragment__take_photo">Take photo</string>
<string name="AvatarSelectionBottomSheetDialogFragment__choose_from_gallery">Choose from gallery</string>
<string name="AvatarSelectionBottomSheetDialogFragment__remove_photo">Remove photo</string>
<!-- DateUtils -->
<string name="DateUtils_just_now">Now</string>
<string name="DateUtils_minutes_ago">%dm</string>

View File

@ -368,6 +368,10 @@
<item name="message_request_text_color_primary">@color/core_grey_90</item>
<item name="message_request_text_color_secondary">@color/core_grey_60</item>
<item name="avatar_selection_take_photo">@drawable/ic_camera_outline_24</item>
<item name="avatar_selection_pick_photo">@drawable/ic_photo_outline_24</item>
<item name="avatar_selection_remove_photo">@drawable/ic_trash_outline_24</item>
<item name="conversation_icon_attach_audio">@drawable/ic_audio_light</item>
<item name="conversation_icon_attach_video">@drawable/ic_video_light</item>
@ -631,6 +635,10 @@
<item name="message_request_text_color_primary">@color/core_grey_05</item>
<item name="message_request_text_color_secondary">@color/core_grey_25</item>
<item name="avatar_selection_take_photo">@drawable/ic_camera_solid_24</item>
<item name="avatar_selection_pick_photo">@drawable/ic_photo_solid_24</item>
<item name="avatar_selection_remove_photo">@drawable/ic_trash_solid_24</item>
<item name="conversation_icon_attach_audio">@drawable/ic_audio_dark</item>
<item name="conversation_icon_attach_video">@drawable/ic_video_dark</item>

View File

@ -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'],