Initial modularization of core image editor code.

This commit is contained in:
Alex Hart
2021-09-13 13:16:14 -03:00
committed by Greyson Parrelli
parent 5d5251054c
commit 95fabd7ed1
61 changed files with 422 additions and 173 deletions

1
image-editor/lib/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,47 @@
plugins {
id 'com.android.library'
id 'witness'
id 'kotlin-android'
id 'kotlin-kapt'
}
apply from: 'witness-verifications.gradle'
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
defaultConfig {
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
multiDexEnabled true
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencyVerification {
configuration = '(debug|release)RuntimeClasspath'
}
dependencies {
lintChecks project(':lintchecks')
implementation project(':core-util')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.annotation:annotation:1.2.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
kapt 'androidx.annotation:annotation:1.2.0'
}

View File

21
image-editor/lib/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.signal.imageeditor">
</manifest>

View File

@@ -0,0 +1,77 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import android.graphics.RectF;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.imageeditor.core.model.EditorElement;
/**
* The local extent of a {@link EditorElement}.
* i.e. all {@link EditorElement}s have a bounding rectangle from:
* <p>
* {@link #LEFT} to {@link #RIGHT} and from {@link #TOP} to {@link #BOTTOM}.
*/
public final class Bounds {
public static final float LEFT = -1000f;
public static final float RIGHT = 1000f;
public static final float TOP = -1000f;
public static final float BOTTOM = 1000f;
public static final float CENTRE_X = (LEFT + RIGHT) / 2f;
public static final float CENTRE_Y = (TOP + BOTTOM) / 2f;
public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y };
private static final float[] POINTS = { Bounds.LEFT, Bounds.TOP,
Bounds.RIGHT, Bounds.TOP,
Bounds.RIGHT, Bounds.BOTTOM,
Bounds.LEFT, Bounds.BOTTOM };
static RectF newFullBounds() {
return new RectF(LEFT, TOP, RIGHT, BOTTOM);
}
public static RectF FULL_BOUNDS = newFullBounds();
public static boolean contains(float x, float y) {
return x >= FULL_BOUNDS.left && x <= FULL_BOUNDS.right &&
y >= FULL_BOUNDS.top && y <= FULL_BOUNDS.bottom;
}
/**
* Maps all the points of bounds with the supplied matrix and determines whether they are still in bounds.
*
* @param matrix matrix to transform points by, null is treated as identity.
* @return true iff all points remain in bounds after transformation.
*/
public static boolean boundsRemainInBounds(@Nullable Matrix matrix) {
if (matrix == null) return true;
float[] dst = new float[POINTS.length];
matrix.mapPoints(dst, POINTS);
return allWithinBounds(dst);
}
private static boolean allWithinBounds(@NonNull float[] points) {
boolean allHit = true;
for (int i = 0; i < points.length / 2; i++) {
float x = points[2 * i];
float y = points[2 * i + 1];
if (!Bounds.contains(x, y)) {
allHit = false;
break;
}
}
return allHit;
}
}

View File

@@ -0,0 +1,79 @@
package org.signal.imageeditor.core;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.RectF;
import androidx.annotation.NonNull;
/**
* Tracks the current matrix for a canvas.
* <p>
* This is because you cannot reliably call {@link Canvas#setMatrix(Matrix)}.
* {@link Canvas#getMatrix()} provides this hint in its documentation:
* "track relevant transform state outside of the canvas."
* <p>
* To achieve this, any changes to the canvas matrix must be done via this class, including save and
* restore operations where the matrix was altered in between.
*/
public final class CanvasMatrix {
private final static int STACK_HEIGHT_LIMIT = 16;
private final Canvas canvas;
private final Matrix canvasMatrix = new Matrix();
private final Matrix temp = new Matrix();
private final Matrix[] stack = new Matrix[STACK_HEIGHT_LIMIT];
private int stackHeight;
CanvasMatrix(Canvas canvas) {
this.canvas = canvas;
for (int i = 0; i < stack.length; i++) {
stack[i] = new Matrix();
}
}
public void concat(@NonNull Matrix matrix) {
canvas.concat(matrix);
canvasMatrix.preConcat(matrix);
}
void save() {
canvas.save();
if (stackHeight == STACK_HEIGHT_LIMIT) {
throw new AssertionError("Not enough space on stack");
}
stack[stackHeight++].set(canvasMatrix);
}
void restore() {
canvas.restore();
canvasMatrix.set(stack[--stackHeight]);
}
void getCurrent(@NonNull Matrix into) {
into.set(canvasMatrix);
}
public void setToIdentity() {
if (canvasMatrix.invert(temp)) {
concat(temp);
}
}
public void initial(Matrix viewMatrix) {
concat(viewMatrix);
}
boolean mapRect(@NonNull RectF dst, @NonNull RectF src) {
return canvasMatrix.mapRect(dst, src);
}
public void mapPoints(float[] dst, float[] src) {
canvasMatrix.mapPoints(dst, src);
}
public void copyTo(@NonNull Matrix matrix) {
matrix.set(canvasMatrix);
}
}

View File

@@ -0,0 +1,16 @@
package org.signal.imageeditor.core;
import androidx.annotation.ColorInt;
/**
* A renderer that can have its color changed.
* <p>
* For example, Lines and Text can change color.
*/
public interface ColorableRenderer extends Renderer {
@ColorInt
int getColor();
void setColor(@ColorInt int color);
}

View File

@@ -0,0 +1,46 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import android.graphics.PointF;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.model.EditorElement;
import org.signal.imageeditor.core.renderers.BezierDrawingRenderer;
/**
* Passes touch events into a {@link BezierDrawingRenderer}.
*/
class DrawingSession extends ElementEditSession {
private final BezierDrawingRenderer renderer;
private DrawingSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull BezierDrawingRenderer renderer) {
super(selected, inverseMatrix);
this.renderer = renderer;
}
public static EditSession start(EditorElement element, BezierDrawingRenderer renderer, Matrix inverseMatrix, PointF point) {
DrawingSession drawingSession = new DrawingSession(element, inverseMatrix, renderer);
drawingSession.setScreenStartPoint(0, point);
renderer.setFirstPoint(drawingSession.startPointElement[0]);
return drawingSession;
}
@Override
public void movePoint(int p, @NonNull PointF point) {
if (p != 0) return;
setScreenEndPoint(p, point);
renderer.addNewPoint(endPointElement[0]);
}
@Override
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
return this;
}
@Override
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
return this;
}
}

View File

@@ -0,0 +1,32 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import android.graphics.PointF;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.model.EditorElement;
/**
* Represents an underway edit of the image.
* <p>
* Accepts new touch positions, new touch points, released touch points and when complete can commit the edit.
* <p>
* Examples of edit session implementations are, Drag, Draw, Resize:
* <p>
* {@link ElementDragEditSession} for dragging with a single finger.
* {@link ElementScaleEditSession} for resize/dragging with two fingers.
* {@link DrawingSession} for drawing with a single finger.
*/
interface EditSession {
void movePoint(int p, @NonNull PointF point);
EditorElement getSelected();
EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p);
EditSession removePoint(@NonNull Matrix newInverse, int p);
void commit();
}

View File

@@ -0,0 +1,43 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import android.graphics.PointF;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.model.EditorElement;
final class ElementDragEditSession extends ElementEditSession {
private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
super(selected, inverseMatrix);
}
static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) {
if (!selected.getFlags().isEditable()) return null;
ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix);
elementDragEditSession.setScreenStartPoint(0, point);
elementDragEditSession.setScreenEndPoint(0, point);
return elementDragEditSession;
}
@Override
public void movePoint(int p, @NonNull PointF point) {
setScreenEndPoint(p, point);
selected.getEditorMatrix()
.setTranslate(endPointElement[0].x - startPointElement[0].x, endPointElement[0].y - startPointElement[0].y);
}
@Override
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
return ElementScaleEditSession.startScale(this, newInverse, point, p);
}
@Override
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
return this;
}
}

View File

@@ -0,0 +1,70 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import android.graphics.PointF;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.model.EditorElement;
abstract class ElementEditSession implements EditSession {
private final Matrix inverseMatrix;
final EditorElement selected;
final PointF[] startPointElement = newTwoPointArray();
final PointF[] endPointElement = newTwoPointArray();
final PointF[] startPointScreen = newTwoPointArray();
final PointF[] endPointScreen = newTwoPointArray();
ElementEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
this.selected = selected;
this.inverseMatrix = inverseMatrix;
}
void setScreenStartPoint(int p, @NonNull PointF point) {
startPointScreen[p] = point;
mapPoint(startPointElement[p], inverseMatrix, point);
}
void setScreenEndPoint(int p, @NonNull PointF point) {
endPointScreen[p] = point;
mapPoint(endPointElement[p], inverseMatrix, point);
}
@Override
public abstract void movePoint(int p, @NonNull PointF point);
@Override
public void commit() {
selected.commitEditorMatrix();
}
@Override
public EditorElement getSelected() {
return selected;
}
private static PointF[] newTwoPointArray() {
PointF[] array = new PointF[2];
for (int i = 0; i < array.length; i++) {
array[i] = new PointF();
}
return array;
}
/**
* Map src to dst using the matrix.
*
* @param dst Output point.
* @param matrix Matrix to transform point with.
* @param src Input point.
*/
private static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) {
float[] in = { src.x, src.y };
float[] out = new float[2];
matrix.mapPoints(out, in);
dst.set(out[0], out[1]);
}
}

View File

@@ -0,0 +1,98 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import android.graphics.PointF;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.model.EditorElement;
final class ElementScaleEditSession extends ElementEditSession {
private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
super(selected, inverseMatrix);
}
static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) {
session.commit();
ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix);
newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]);
newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]);
newSession.setScreenStartPoint(p, point);
newSession.setScreenEndPoint(p, point);
return newSession;
}
@Override
public void movePoint(int p, @NonNull PointF point) {
setScreenEndPoint(p, point);
Matrix editorMatrix = selected.getEditorMatrix();
editorMatrix.reset();
if (selected.getFlags().isAspectLocked()) {
float scale = (float) findScale(startPointElement, endPointElement);
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
editorMatrix.postScale(scale, scale);
double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]);
if (!selected.getFlags().isRotateLocked()) {
editorMatrix.postRotate((float) Math.toDegrees(angle));
}
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
} else {
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
float scaleX = (endPointElement[1].x - endPointElement[0].x) / (startPointElement[1].x - startPointElement[0].x);
float scaleY = (endPointElement[1].y - endPointElement[0].y) / (startPointElement[1].y - startPointElement[0].y);
editorMatrix.postScale(scaleX, scaleY);
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
}
}
@Override
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
return this;
}
@Override
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
return convertToDrag(p, newInverse);
}
private static double angle(@NonNull PointF a, @NonNull PointF b) {
return Math.atan2(a.y - b.y, a.x - b.x);
}
private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) {
return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]);
}
/**
* Find relative distance between an old and new set of Points.
*
* @param from Pair of points.
* @param to New pair of points.
* @return Scale
*/
private static double findScale(@NonNull PointF[] from, @NonNull PointF[] to) {
float originalD2 = getDistanceSquared(from[0], from[1]);
float newD2 = getDistanceSquared(to[0], to[1]);
return Math.sqrt(newD2 / originalD2);
}
/**
* Distance between two points squared.
*/
private static float getDistanceSquared(@NonNull PointF a, @NonNull PointF b) {
float dx = a.x - b.x;
float dy = a.y - b.y;
return dx * dx + dy * dy;
}
}

View File

@@ -0,0 +1,176 @@
package org.signal.imageeditor.core;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Rect;
import android.text.InputType;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.imageeditor.core.model.EditorElement;
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
/**
* Invisible {@link android.widget.EditText} that is used during in-image text editing.
*/
public final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText {
@SuppressLint("InlinedApi")
private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING;
@Nullable
private EditorElement currentTextEditorElement;
@Nullable
private MultiLineTextRenderer currentTextEntity;
@Nullable
private Runnable onEndEdit;
@Nullable
private OnEditOrSelectionChange onEditOrSelectionChange;
public HiddenEditText(Context context) {
super(context);
setAlpha(0);
setLayoutParams(new FrameLayout.LayoutParams(1, 1, Gravity.TOP | Gravity.START));
setClickable(false);
setFocusable(true);
setFocusableInTouchMode(true);
setBackgroundColor(Color.TRANSPARENT);
setTextSize(TypedValue.COMPLEX_UNIT_SP, 1);
setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
clearFocus();
}
@Override
protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
super.onTextChanged(text, start, lengthBefore, lengthAfter);
if (currentTextEntity != null) {
currentTextEntity.setText(text.toString());
postEditOrSelectionChange();
}
}
@Override
public void onEditorAction(int actionCode) {
super.onEditorAction(actionCode);
if (actionCode == EditorInfo.IME_ACTION_DONE && currentTextEntity != null) {
currentTextEntity.setFocused(false);
endEdit();
}
}
@Override
protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(focused, direction, previouslyFocusedRect);
if (currentTextEntity != null) {
currentTextEntity.setFocused(focused);
if (!focused) {
endEdit();
}
}
}
private void endEdit() {
if (onEndEdit != null) {
onEndEdit.run();
}
}
private void postEditOrSelectionChange() {
if (currentTextEditorElement != null && currentTextEntity != null && onEditOrSelectionChange != null) {
onEditOrSelectionChange.onChange(currentTextEditorElement, currentTextEntity);
}
}
@Nullable MultiLineTextRenderer getCurrentTextEntity() {
return currentTextEntity;
}
@Nullable EditorElement getCurrentTextEditorElement() {
return currentTextEditorElement;
}
public void setCurrentTextEditorElement(@Nullable EditorElement currentTextEditorElement) {
if (currentTextEditorElement != null && currentTextEditorElement.getRenderer() instanceof MultiLineTextRenderer) {
this.currentTextEditorElement = currentTextEditorElement;
setCurrentTextEntity((MultiLineTextRenderer) currentTextEditorElement.getRenderer());
} else {
this.currentTextEditorElement = null;
setCurrentTextEntity(null);
}
postEditOrSelectionChange();
}
private void setCurrentTextEntity(@Nullable MultiLineTextRenderer currentTextEntity) {
if (this.currentTextEntity != currentTextEntity) {
if (this.currentTextEntity != null) {
this.currentTextEntity.setFocused(false);
}
this.currentTextEntity = currentTextEntity;
if (currentTextEntity != null) {
String text = currentTextEntity.getText();
setText(text);
setSelection(text.length());
} else {
setText("");
}
}
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (currentTextEntity != null) {
currentTextEntity.setSelection(selStart, selEnd);
postEditOrSelectionChange();
}
}
@Override
public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
boolean focus = super.requestFocus(direction, previouslyFocusedRect);
if (currentTextEntity != null && focus) {
currentTextEntity.setFocused(true);
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT);
if (!imm.isAcceptingText()) {
imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY);
}
}
return focus;
}
public void hideKeyboard() {
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY);
}
public void setIncognitoKeyboardEnabled(boolean incognitoKeyboardEnabled) {
setImeOptions(incognitoKeyboardEnabled ? getImeOptions() | INCOGNITO_KEYBOARD_IME
: getImeOptions() & ~INCOGNITO_KEYBOARD_IME);
}
public void setOnEndEdit(@Nullable Runnable onEndEdit) {
this.onEndEdit = onEndEdit;
}
public void setOnEditOrSelectionChange(@Nullable OnEditOrSelectionChange onEditOrSelectionChange) {
this.onEditOrSelectionChange = onEditOrSelectionChange;
}
public interface OnEditOrSelectionChange {
void onChange(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer);
}
}

View File

@@ -0,0 +1,551 @@
package org.signal.imageeditor.core;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.widget.FrameLayout;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.GestureDetectorCompat;
import org.signal.imageeditor.core.model.EditorElement;
import org.signal.imageeditor.core.model.EditorModel;
import org.signal.imageeditor.core.model.ThumbRenderer;
import org.signal.imageeditor.core.renderers.BezierDrawingRenderer;
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
import org.signal.imageeditor.core.renderers.TrashRenderer;
/**
* ImageEditorView
* <p>
* Android {@link android.view.View} that allows manipulation of a base image, rotate/flip/crop and
* addition and manipulation of text/drawing/and other image layers that move with the base image.
* <p>
* Drawing
* <p>
* Drawing is achieved by setting the {@link #color} and putting the view in {@link Mode#Draw}.
* Touch events are then passed to a new {@link BezierDrawingRenderer} on a new {@link EditorElement}.
* <p>
* New images
* <p>
* To add new images to the base image add via the {@link EditorModel#addElementCentered(EditorElement, float)}
* which centers the new item in the current crop area.
*/
public final class ImageEditorView extends FrameLayout {
private HiddenEditText editText;
@NonNull
private Mode mode = Mode.MoveAndResize;
@ColorInt
private int color = 0xff000000;
private float thickness = 0.02f;
@NonNull
private Paint.Cap cap = Paint.Cap.ROUND;
private EditorModel model;
private GestureDetectorCompat doubleTap;
@Nullable
private DrawingChangedListener drawingChangedListener;
@Nullable
private SizeChangedListener sizeChangedListener;
@Nullable
private UndoRedoStackListener undoRedoStackListener;
@Nullable
private DragListener dragListener;
private final Matrix viewMatrix = new Matrix();
private final RectF viewPort = Bounds.newFullBounds();
private final RectF visibleViewPort = Bounds.newFullBounds();
private final RectF screen = new RectF();
private TapListener tapListener;
private RendererContext rendererContext;
@Nullable
private EditSession editSession;
private boolean moreThanOnePointerUsedInSession;
public ImageEditorView(Context context) {
super(context);
init();
}
public ImageEditorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public ImageEditorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setWillNotDraw(false);
setModel(EditorModel.create());
editText = createAHiddenTextEntryField();
doubleTap = new GestureDetectorCompat(getContext(), new DoubleTapGestureListener());
setOnTouchListener((v, event) -> doubleTap.onTouchEvent(event));
}
private HiddenEditText createAHiddenTextEntryField() {
HiddenEditText editText = new HiddenEditText(getContext());
addView(editText);
editText.clearFocus();
editText.setOnEndEdit(this::doneTextEditing);
editText.setOnEditOrSelectionChange(this::zoomToFitText);
return editText;
}
public void startTextEditing(@NonNull EditorElement editorElement) {
getModel().addFade();
if (editorElement.getRenderer() instanceof MultiLineTextRenderer) {
editText.setCurrentTextEditorElement(editorElement);
}
}
public void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) {
getModel().zoomToTextElement(editorElement, textRenderer);
}
public boolean isTextEditing() {
return editText.getCurrentTextEntity() != null;
}
public void doneTextEditing() {
getModel().zoomOut();
getModel().removeFade();
if (editText.getCurrentTextEntity() != null) {
editText.setCurrentTextEditorElement(null);
editText.hideKeyboard();
}
}
@Override
protected void onDraw(Canvas canvas) {
if (rendererContext == null || rendererContext.canvas != canvas) {
rendererContext = new RendererContext(getContext(), canvas, rendererReady, rendererInvalidate);
}
rendererContext.save();
try {
rendererContext.canvasMatrix.initial(viewMatrix);
model.draw(rendererContext, editText.getCurrentTextEditorElement());
} finally {
rendererContext.restore();
}
}
private final RendererContext.Ready rendererReady = new RendererContext.Ready() {
@Override
public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) {
model.onReady(renderer, cropMatrix, size);
invalidate();
}
};
private final RendererContext.Invalidate rendererInvalidate = renderer -> invalidate();
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
updateViewMatrix();
if (sizeChangedListener != null) {
sizeChangedListener.onSizeChanged(w, h);
}
}
private void updateViewMatrix() {
screen.right = getWidth();
screen.bottom = getHeight();
viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL);
float[] values = new float[9];
viewMatrix.getValues(values);
float scale = values[0] / values[4];
RectF tempViewPort = Bounds.newFullBounds();
if (scale < 1) {
tempViewPort.top /= scale;
tempViewPort.bottom /= scale;
} else {
tempViewPort.left *= scale;
tempViewPort.right *= scale;
}
visibleViewPort.set(tempViewPort);
viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER);
model.setVisibleViewPort(visibleViewPort);
invalidate();
}
public void setModel(@NonNull EditorModel model) {
if (this.model != model) {
if (this.model != null) {
this.model.setInvalidate(null);
this.model.setUndoRedoStackListener(null);
}
this.model = model;
this.model.setInvalidate(this::invalidate);
this.model.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged);
this.model.setVisibleViewPort(visibleViewPort);
invalidate();
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
Matrix inverse = new Matrix();
PointF point = getPoint(event);
EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse);
moreThanOnePointerUsedInSession = false;
model.pushUndoPoint();
editSession = startEdit(inverse, point, selected);
if (editSession != null) {
checkTrashIntersect(point);
notifyDragStart(editSession.getSelected());
}
if (tapListener != null && allowTaps()) {
if (editSession != null) {
tapListener.onEntityDown(editSession.getSelected());
} else {
tapListener.onEntityDown(null);
}
}
return true;
}
case MotionEvent.ACTION_MOVE: {
if (editSession != null) {
int historySize = event.getHistorySize();
int pointerCount = Math.min(2, event.getPointerCount());
for (int h = 0; h < historySize; h++) {
for (int p = 0; p < pointerCount; p++) {
editSession.movePoint(p, getHistoricalPoint(event, p, h));
}
}
for (int p = 0; p < pointerCount; p++) {
editSession.movePoint(p, getPoint(event, p));
}
model.moving(editSession.getSelected());
invalidate();
notifyDragMove(editSession.getSelected(), checkTrashIntersect(getPoint(event)));
return true;
}
break;
}
case MotionEvent.ACTION_POINTER_DOWN: {
if (editSession != null && event.getPointerCount() == 2) {
moreThanOnePointerUsedInSession = true;
editSession.commit();
model.pushUndoPoint();
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
if (newInverse != null) {
editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex());
} else {
editSession = null;
}
if (editSession == null) {
dragDropRelease(false);
}
return true;
}
break;
}
case MotionEvent.ACTION_POINTER_UP: {
if (editSession != null && event.getActionIndex() < 2) {
editSession.commit();
model.pushUndoPoint();
dragDropRelease(true);
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
if (newInverse != null) {
editSession = editSession.removePoint(newInverse, event.getActionIndex());
} else {
editSession = null;
}
return true;
}
break;
}
case MotionEvent.ACTION_UP: {
if (editSession != null) {
editSession.commit();
dragDropRelease(false);
PointF point = getPoint(event);
boolean hittingTrash = event.getPointerCount() == 1 &&
checkTrashIntersect(point) &&
model.findElementAtPoint(point, viewMatrix, new Matrix()) == editSession.getSelected();
notifyDragEnd(editSession.getSelected(), hittingTrash);
editSession = null;
model.postEdit(moreThanOnePointerUsedInSession);
invalidate();
return true;
} else {
model.postEdit(moreThanOnePointerUsedInSession);
}
break;
}
}
return super.onTouchEvent(event);
}
private boolean checkTrashIntersect(@NonNull PointF point) {
if (mode == Mode.Draw || mode == Mode.Blur) {
return false;
}
if (model.checkTrashIntersectsPoint(point)) {
if (model.getTrash().getRenderer() instanceof TrashRenderer) {
((TrashRenderer) model.getTrash().getRenderer()).expand();
}
return true;
} else {
if (model.getTrash().getRenderer() instanceof TrashRenderer) {
((TrashRenderer) model.getTrash().getRenderer()).shrink();
}
return false;
}
}
private void notifyDragStart(@Nullable EditorElement editorElement) {
if (dragListener != null) {
dragListener.onDragStarted(editorElement);
}
}
private void notifyDragMove(@Nullable EditorElement editorElement, boolean isInTrashHitZone) {
if (dragListener != null) {
dragListener.onDragMoved(editorElement, isInTrashHitZone);
}
}
private void notifyDragEnd(@Nullable EditorElement editorElement, boolean isInTrashHitZone) {
if (dragListener != null) {
dragListener.onDragEnded(editorElement, isInTrashHitZone);
}
}
private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
EditSession editSession = startAMoveAndResizeSession(inverse, point, selected);
if (editSession == null && (mode == Mode.Draw || mode == Mode.Blur)) {
return startADrawingSession(point);
} else {
setMode(Mode.MoveAndResize);
return editSession;
}
}
private EditSession startADrawingSession(@NonNull PointF point) {
BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot());
EditorElement element = new EditorElement(renderer, mode == Mode.Blur ? EditorModel.Z_MASK : EditorModel.Z_DRAWING);
model.addElementCentered(element, 1);
Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix);
return DrawingSession.start(element, renderer, elementInverseMatrix, point);
}
private EditSession startAMoveAndResizeSession(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
Matrix elementInverseMatrix;
if (selected == null) return null;
if (selected.getRenderer() instanceof ThumbRenderer) {
ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer();
selected = getModel().findById(thumb.getElementToControl());
if (selected == null) return null;
elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix);
if (elementInverseMatrix != null) {
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumb.getControlPoint(), point);
} else {
return null;
}
}
return ElementDragEditSession.startDrag(selected, inverse, point);
}
@NonNull
public Mode getMode() {
return mode;
}
public void setMode(@NonNull Mode mode) {
this.mode = mode;
}
public void startDrawing(float thickness, @NonNull Paint.Cap cap, boolean blur) {
this.thickness = thickness;
this.cap = cap;
setMode(blur ? Mode.Blur : Mode.Draw);
}
public void setDrawingBrushColor(int color) {
this.color = color;
}
private void dragDropRelease(boolean stillTouching) {
model.dragDropRelease();
if (drawingChangedListener != null) {
drawingChangedListener.onDrawingChanged(stillTouching);
}
}
private static PointF getPoint(MotionEvent event) {
return getPoint(event, 0);
}
private static PointF getPoint(MotionEvent event, int p) {
return new PointF(event.getX(p), event.getY(p));
}
private static PointF getHistoricalPoint(MotionEvent event, int p, int historicalIndex) {
return new PointF(event.getHistoricalX(p, historicalIndex),
event.getHistoricalY(p, historicalIndex));
}
public EditorModel getModel() {
return model;
}
public void setDrawingChangedListener(@Nullable DrawingChangedListener drawingChangedListener) {
this.drawingChangedListener = drawingChangedListener;
}
public void setSizeChangedListener(@Nullable SizeChangedListener sizeChangedListener) {
this.sizeChangedListener = sizeChangedListener;
}
public void setUndoRedoStackListener(@Nullable UndoRedoStackListener undoRedoStackListener) {
this.undoRedoStackListener = undoRedoStackListener;
}
public void setDragListener(@Nullable DragListener dragListener) {
this.dragListener = dragListener;
}
public void setTapListener(TapListener tapListener) {
this.tapListener = tapListener;
}
public void deleteElement(@Nullable EditorElement editorElement) {
if (editorElement != null) {
model.delete(editorElement);
invalidate();
}
}
private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) {
if (undoRedoStackListener != null) {
undoRedoStackListener.onAvailabilityChanged(undoAvailable, redoAvailable);
}
}
private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDoubleTap(MotionEvent e) {
if (tapListener != null && editSession != null && allowTaps()) {
tapListener.onEntityDoubleTap(editSession.getSelected());
}
return true;
}
@Override
public void onLongPress(MotionEvent e) {}
@Override
public boolean onSingleTapUp(MotionEvent e) {
if (tapListener != null && allowTaps()) {
if (editSession != null) {
EditorElement selected = editSession.getSelected();
model.indicateSelected(selected);
tapListener.onEntitySingleTap(selected);
} else {
tapListener.onEntitySingleTap(null);
}
return true;
}
return false;
}
@Override
public boolean onDown(MotionEvent e) {
return false;
}
}
private boolean allowTaps() {
return !model.isCropping() && mode != Mode.Draw && mode != Mode.Blur;
}
public enum Mode {
MoveAndResize,
Draw,
Blur
}
public interface DrawingChangedListener {
void onDrawingChanged(boolean stillTouching);
}
public interface SizeChangedListener {
void onSizeChanged(int newWidth, int newHeight);
}
public interface DragListener {
void onDragStarted(@Nullable EditorElement editorElement);
void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone);
void onDragEnded(@Nullable EditorElement editorElement, boolean isInTrashHitZone);
}
public interface TapListener {
void onEntityDown(@Nullable EditorElement editorElement);
void onEntitySingleTap(@Nullable EditorElement editorElement);
void onEntityDoubleTap(@NonNull EditorElement editorElement);
}
}

View File

@@ -0,0 +1,29 @@
package org.signal.imageeditor.core;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.model.EditorElement;
/**
* Responsible for rendering a single {@link EditorElement} to the canvas.
* <p>
* Because it knows the most about the whereabouts of the image it is also responsible for hit detection.
*/
public interface Renderer extends Parcelable {
/**
* Draw self to the context.
*
* @param rendererContext The context to draw to.
*/
void render(@NonNull RendererContext rendererContext);
/**
* @param x Local coordinate X
* @param y Local coordinate Y
* @return true iff hit.
*/
boolean hitTest(float x, float y);
}

View File

@@ -0,0 +1,144 @@
package org.signal.imageeditor.core;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.RectF;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.imageeditor.core.model.EditorElement;
import java.util.Collections;
import java.util.List;
/**
* Contains all of the information required for a {@link Renderer} to do its job.
* <p>
* Includes a {@link #canvas}, preconfigured with the correct matrix.
* <p>
* The {@link #canvasMatrix} should further matrix manipulation be required.
*/
public final class RendererContext {
@NonNull
public final Context context;
@NonNull
public final Canvas canvas;
@NonNull
public final CanvasMatrix canvasMatrix;
@NonNull
public final Ready rendererReady;
@NonNull
public final Invalidate invalidate;
private boolean blockingLoad;
private float fade = 1f;
private boolean isEditing = true;
private List<EditorElement> children = Collections.emptyList();
private Paint maskPaint;
public RendererContext(@NonNull Context context, @NonNull Canvas canvas, @NonNull Ready rendererReady, @NonNull Invalidate invalidate) {
this.context = context;
this.canvas = canvas;
this.canvasMatrix = new CanvasMatrix(canvas);
this.rendererReady = rendererReady;
this.invalidate = invalidate;
}
public void setBlockingLoad(boolean blockingLoad) {
this.blockingLoad = blockingLoad;
}
/**
* {@link Renderer}s generally run in the foreground but can load any data they require in the background.
* <p>
* If they do so, they can use the {@link #invalidate} callback when ready to inform the view it needs to be redrawn.
* <p>
* However, when isBlockingLoad is true, the renderer is running in the background for the final render
* and must load the data immediately and block the render until done so.
*/
public boolean isBlockingLoad() {
return blockingLoad;
}
public boolean mapRect(@NonNull RectF dst, @NonNull RectF src) {
return canvasMatrix.mapRect(dst, src);
}
public void setIsEditing(boolean isEditing) {
this.isEditing = isEditing;
}
public boolean isEditing() {
return isEditing;
}
public void setFade(float fade) {
this.fade = fade;
}
public int getAlpha(int alpha) {
return Math.max(0, Math.min(255, (int) (fade * alpha)));
}
/**
* Persist the current state on to a stack, must be complimented by a call to {@link #restore()}.
*/
public void save() {
canvasMatrix.save();
}
/**
* Restore the current state from the stack, must match a call to {@link #save()}.
*/
public void restore() {
canvasMatrix.restore();
}
public void getCurrent(@NonNull Matrix into) {
canvasMatrix.getCurrent(into);
}
public void setChildren(@NonNull List<EditorElement> children) {
this.children = children;
}
public @NonNull List<EditorElement> getChildren() {
return children;
}
public void setMaskPaint(@Nullable Paint maskPaint) {
this.maskPaint = maskPaint;
}
public @Nullable Paint getMaskPaint() {
return maskPaint;
}
public interface Ready {
Ready NULL = (renderer, cropMatrix, size) -> {
};
void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size);
}
public interface Invalidate {
Invalidate NULL = (renderer) -> {
};
void onInvalidate(@NonNull Renderer renderer);
}
}

View File

@@ -0,0 +1,8 @@
package org.signal.imageeditor.core
/**
* Renderer that can maintain a "selected" state
*/
interface SelectableRenderer : Renderer {
fun onSelected(selected: Boolean)
}

View File

@@ -0,0 +1,79 @@
package org.signal.imageeditor.core;
import android.graphics.Matrix;
import android.graphics.PointF;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.model.EditorElement;
import org.signal.imageeditor.core.model.ThumbRenderer;
class ThumbDragEditSession extends ElementEditSession {
@NonNull
private final ThumbRenderer.ControlPoint controlPoint;
private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix) {
super(selected, inverseMatrix);
this.controlPoint = controlPoint;
}
static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull PointF point) {
if (!selected.getFlags().isEditable()) return null;
ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix);
elementDragEditSession.setScreenStartPoint(0, point);
elementDragEditSession.setScreenEndPoint(0, point);
return elementDragEditSession;
}
@Override
public void movePoint(int p, @NonNull PointF point) {
setScreenEndPoint(p, point);
Matrix editorMatrix = selected.getEditorMatrix();
editorMatrix.reset();
float x = controlPoint.opposite().getX();
float y = controlPoint.opposite().getY();
float dx = endPointElement[0].x - startPointElement[0].x;
float dy = endPointElement[0].y - startPointElement[0].y;
float xEnd = controlPoint.getX() + dx;
float yEnd = controlPoint.getY() + dy;
boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter();
float defaultScale = aspectLocked ? 2 : 1;
float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (xEnd - x) / (controlPoint.getX() - x);
float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (yEnd - y) / (controlPoint.getY() - y);
scale(editorMatrix, aspectLocked, scaleX, scaleY, controlPoint.opposite());
}
private void scale(Matrix editorMatrix, boolean aspectLocked, float scaleX, float scaleY, ThumbRenderer.ControlPoint around) {
float x = around.getX();
float y = around.getY();
editorMatrix.postTranslate(-x, -y);
if (aspectLocked) {
float minScale = Math.min(scaleX, scaleY);
editorMatrix.postScale(minScale, minScale);
} else {
editorMatrix.postScale(scaleX, scaleY);
}
editorMatrix.postTranslate(x, y);
}
@Override
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
return null;
}
@Override
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
return null;
}
}

View File

@@ -0,0 +1,6 @@
package org.signal.imageeditor.core;
public interface UndoRedoStackListener {
void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable);
}

View File

@@ -0,0 +1,64 @@
package org.signal.imageeditor.core.model;
import android.animation.ValueAnimator;
import android.view.animation.Interpolator;
import android.view.animation.LinearInterpolator;
import androidx.annotation.Nullable;
final class AlphaAnimation {
private final static Interpolator interpolator = new LinearInterpolator();
final static AlphaAnimation NULL_1 = new AlphaAnimation(1);
private final float from;
private final float to;
private final Runnable invalidate;
private final boolean canAnimate;
private float animatedFraction;
private AlphaAnimation(float from, float to, @Nullable Runnable invalidate) {
this.from = from;
this.to = to;
this.invalidate = invalidate;
this.canAnimate = invalidate != null;
}
private AlphaAnimation(float fixed) {
this(fixed, fixed, null);
}
static AlphaAnimation animate(float from, float to, @Nullable Runnable invalidate) {
if (invalidate == null) {
return new AlphaAnimation(to);
}
if (from != to) {
AlphaAnimation animationMatrix = new AlphaAnimation(from, to, invalidate);
animationMatrix.start();
return animationMatrix;
} else {
return new AlphaAnimation(to);
}
}
private void start() {
if (canAnimate && invalidate != null) {
ValueAnimator animator = ValueAnimator.ofFloat(from, to);
animator.setDuration(200);
animator.setInterpolator(interpolator);
animator.addUpdateListener(animation -> {
animatedFraction = (float) animation.getAnimatedValue();
invalidate.run();
});
animator.start();
}
}
float getValue() {
if (!canAnimate) return to;
return animatedFraction;
}
}

View File

@@ -0,0 +1,137 @@
package org.signal.imageeditor.core.model;
import android.animation.ValueAnimator;
import android.graphics.Matrix;
import android.view.animation.CycleInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.imageeditor.core.CanvasMatrix;
/**
* Animation Matrix provides a matrix that animates over time down to the identity matrix.
*/
final class AnimationMatrix {
private final static float[] iValues = new float[9];
private final static Interpolator interpolator = new DecelerateInterpolator();
private final static Interpolator pulseInterpolator = inverse(new CycleInterpolator(0.5f));
static AnimationMatrix NULL = new AnimationMatrix();
static {
new Matrix().getValues(iValues);
}
private final Runnable invalidate;
private final boolean canAnimate;
private final float[] undoValues = new float[9];
private final Matrix temp = new Matrix();
private final float[] tempValues = new float[9];
private ValueAnimator animator;
private float animatedFraction;
private AnimationMatrix(@NonNull Matrix undo, @NonNull Runnable invalidate) {
this.invalidate = invalidate;
this.canAnimate = true;
undo.getValues(undoValues);
}
private AnimationMatrix() {
canAnimate = false;
invalidate = null;
}
static @NonNull AnimationMatrix animate(@NonNull Matrix from, @NonNull Matrix to, @Nullable Runnable invalidate) {
if (invalidate == null) {
return NULL;
}
Matrix undo = new Matrix();
boolean inverted = to.invert(undo);
if (inverted) {
undo.preConcat(from);
}
if (inverted && !undo.isIdentity()) {
AnimationMatrix animationMatrix = new AnimationMatrix(undo, invalidate);
animationMatrix.start(interpolator);
return animationMatrix;
} else {
return NULL;
}
}
/**
* Animate applying a matrix and then animate removing.
*/
static @NonNull AnimationMatrix singlePulse(@NonNull Matrix pulse, @Nullable Runnable invalidate) {
if (invalidate == null) {
return NULL;
}
AnimationMatrix animationMatrix = new AnimationMatrix(pulse, invalidate);
animationMatrix.start(pulseInterpolator);
return animationMatrix;
}
private void start(@NonNull Interpolator interpolator) {
if (canAnimate) {
animator = ValueAnimator.ofFloat(1, 0);
animator.setDuration(250);
animator.setInterpolator(interpolator);
animator.addUpdateListener(animation -> {
animatedFraction = (float) animation.getAnimatedValue();
invalidate.run();
});
animator.start();
}
}
void stop() {
ValueAnimator animator = this.animator;
if (animator != null) animator.cancel();
}
/**
* Append the current animation value.
*/
void preConcatValueTo(@NonNull Matrix onTo) {
if (!canAnimate) return;
onTo.preConcat(buildTemp());
}
/**
* Append the current animation value.
*/
void preConcatValueTo(@NonNull CanvasMatrix canvasMatrix) {
if (!canAnimate) return;
canvasMatrix.concat(buildTemp());
}
private Matrix buildTemp() {
if (!canAnimate) {
temp.reset();
return temp;
}
final float fractionCompliment = 1f - animatedFraction;
for (int i = 0; i < 9; i++) {
tempValues[i] = fractionCompliment * iValues[i] + animatedFraction * undoValues[i];
}
temp.setValues(tempValues);
return temp;
}
private static Interpolator inverse(@NonNull Interpolator interpolator) {
return input -> 1f - interpolator.getInterpolation(input);
}
}

View File

@@ -0,0 +1,114 @@
package org.signal.imageeditor.core.model;
import android.graphics.Matrix;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
final class Bisect {
static final float ACCURACY = 0.001f;
private static final int MAX_ITERATIONS = 16;
interface Predicate {
boolean test();
}
interface ModifyElement {
void applyFactor(@NonNull Matrix matrix, float factor);
}
/**
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
* If it returns true, it will animate the element to the closest true value found to that boundary.
*
* @param element The element to modify.
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
* @param atMost A value believed to be in bounds.
* @param predicate The out of bounds predicate.
* @param modifyElement Apply the latest value to the element local matrix.
* @param invalidate For animation if finds a result.
* @return true iff finds a result.
*/
static boolean bisectToTest(@NonNull EditorElement element,
float outOfBoundsValue,
float atMost,
@NonNull Predicate predicate,
@NonNull ModifyElement modifyElement,
@NonNull Runnable invalidate)
{
Matrix closestSuccesful = bisectToTest(element, outOfBoundsValue, atMost, predicate, modifyElement);
if (closestSuccesful != null) {
element.animateLocalTo(closestSuccesful, invalidate);
return true;
} else {
return false;
}
}
/**
* Given a predicate function, attempts to finds the boundary between predicate true and predicate false.
* Returns new local matrix for the element if a solution is found.
*
* @param element The element to modify.
* @param outOfBoundsValue The current value, known to be out of bounds. 1 for a scale and 0 for a translate.
* @param atMost A value believed to be in bounds.
* @param predicate The out of bounds predicate.
* @param modifyElement Apply the latest value to the element local matrix.
* @return matrix to replace local matrix iff finds a result, null otherwise.
*/
static @Nullable Matrix bisectToTest(@NonNull EditorElement element,
float outOfBoundsValue,
float atMost,
@NonNull Predicate predicate,
@NonNull ModifyElement modifyElement)
{
Matrix elementMatrix = element.getLocalMatrix();
Matrix original = new Matrix(elementMatrix);
Matrix closestSuccessful = new Matrix();
boolean haveResult = false;
int attempt = 0;
float successValue = 0;
float inBoundsValue = atMost;
float nextValueToTry = inBoundsValue;
do {
attempt++;
modifyElement.applyFactor(elementMatrix, nextValueToTry);
try {
if (predicate.test()) {
inBoundsValue = nextValueToTry;
// if first success or closer to out of bounds than the current closest
if (!haveResult || Math.abs(nextValueToTry - outOfBoundsValue) < Math.abs(successValue - outOfBoundsValue)) {
haveResult = true;
successValue = nextValueToTry;
closestSuccessful.set(elementMatrix);
}
} else {
if (attempt == 1) {
// failure on first attempt means inBoundsValue is actually out of bounds and so no solution
return null;
}
outOfBoundsValue = nextValueToTry;
}
} finally {
// reset
elementMatrix.set(original);
}
nextValueToTry = (inBoundsValue + outOfBoundsValue) / 2f;
} while (attempt < MAX_ITERATIONS && Math.abs(inBoundsValue - outOfBoundsValue) > ACCURACY);
if (haveResult) {
return closestSuccessful;
}
return null;
}
}

View File

@@ -0,0 +1,84 @@
package org.signal.imageeditor.core.model;
import android.graphics.Matrix;
import android.os.Parcel;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.R;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
import java.util.UUID;
/**
* Hit tests a circle that is {@link R.dimen#crop_area_renderer_edge_size} in radius on the screen.
* <p>
* Does not draw anything.
*/
class CropThumbRenderer implements Renderer, ThumbRenderer {
private final ControlPoint controlPoint;
private final UUID toControl;
private final float[] centreOnScreen = new float[2];
private final Matrix matrix = new Matrix();
private int size;
CropThumbRenderer(@NonNull ControlPoint controlPoint, @NonNull UUID toControl) {
this.controlPoint = controlPoint;
this.toControl = toControl;
}
@Override
public ControlPoint getControlPoint() {
return controlPoint;
}
@Override
public UUID getElementToControl() {
return toControl;
}
@Override
public void render(@NonNull RendererContext rendererContext) {
rendererContext.canvasMatrix.mapPoints(centreOnScreen, Bounds.CENTRE);
rendererContext.canvasMatrix.copyTo(matrix);
size = rendererContext.context.getResources().getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size);
}
@Override
public boolean hitTest(float x, float y) {
float[] hitPointOnScreen = new float[2];
matrix.mapPoints(hitPointOnScreen, new float[]{ x, y });
float dx = centreOnScreen[0] - hitPointOnScreen[0];
float dy = centreOnScreen[1] - hitPointOnScreen[1];
return dx * dx + dy * dy < size * size;
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<CropThumbRenderer> CREATOR = new Creator<CropThumbRenderer>() {
@Override
public CropThumbRenderer createFromParcel(Parcel in) {
return new CropThumbRenderer(ControlPoint.values()[in.readInt()], ParcelUtils.readUUID(in));
}
@Override
public CropThumbRenderer[] newArray(int size) {
return new CropThumbRenderer[size];
}
};
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(controlPoint.ordinal());
ParcelUtils.writeUUID(dest, toControl);
}
}

View File

@@ -0,0 +1,362 @@
package org.signal.imageeditor.core.model;
import android.graphics.Matrix;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* An image consists of a tree of {@link EditorElement}s.
* <p>
* Each element has some persisted state:
* - An optional {@link Renderer} so that it can draw itself.
* - A list of child elements that make the tree possible.
* - Its own transformation matrix, which applies to itself and all its children.
* - A set of flags controlling visibility, selectablity etc.
* <p>
* Then some temporary state.
* - A editor matrix for displaying as yet uncommitted edits.
* - An animation matrix for animating from one matrix to another.
* - Deleted children to allow them to fade out on delete.
* - Temporary flags, for temporary visibility, selectablity etc.
*/
public final class EditorElement implements Parcelable {
private static final Comparator<EditorElement> Z_ORDER_COMPARATOR = (e1, e2) -> Integer.compare(e1.zOrder, e2.zOrder);
private final UUID id;
private final EditorFlags flags;
private final Matrix localMatrix = new Matrix();
private final Matrix editorMatrix = new Matrix();
private final int zOrder;
@Nullable
private final Renderer renderer;
private final Matrix temp = new Matrix();
private final Matrix tempMatrix = new Matrix();
private final List<EditorElement> children = new LinkedList<>();
private final List<EditorElement> deletedChildren = new LinkedList<>();
@NonNull
private AnimationMatrix animationMatrix = AnimationMatrix.NULL;
@NonNull
private AlphaAnimation alphaAnimation = AlphaAnimation.NULL_1;
public EditorElement(@Nullable Renderer renderer) {
this(renderer, 0);
}
public EditorElement(@Nullable Renderer renderer, int zOrder) {
this.id = UUID.randomUUID();
this.flags = new EditorFlags();
this.renderer = renderer;
this.zOrder = zOrder;
}
private EditorElement(Parcel in) {
id = ParcelUtils.readUUID(in);
flags = new EditorFlags(in.readInt());
ParcelUtils.readMatrix(localMatrix, in);
renderer = in.readParcelable(Renderer.class.getClassLoader());
zOrder = in.readInt();
in.readTypedList(children, EditorElement.CREATOR);
}
UUID getId() {
return id;
}
public @Nullable Renderer getRenderer() {
return renderer;
}
/**
* Iff Visible,
* Renders tree with the following localMatrix:
* <p>
* viewModelMatrix * localMatrix * editorMatrix * animationMatrix
* <p>
* Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * localMatrix * editorMatrix * animationMatrix
*
* @param rendererContext Canvas to draw on to.
*/
public void draw(@NonNull RendererContext rendererContext) {
if (!flags.isVisible() && !flags.isChildrenVisible()) return;
rendererContext.save();
rendererContext.canvasMatrix.concat(localMatrix);
if (rendererContext.isEditing()) {
rendererContext.canvasMatrix.concat(editorMatrix);
animationMatrix.preConcatValueTo(rendererContext.canvasMatrix);
}
if (flags.isVisible()) {
float alpha = alphaAnimation.getValue();
if (alpha > 0) {
rendererContext.setFade(alpha);
rendererContext.setChildren(children);
drawSelf(rendererContext);
rendererContext.setFade(1f);
}
}
if (flags.isChildrenVisible()) {
drawChildren(children, rendererContext);
drawChildren(deletedChildren, rendererContext);
}
rendererContext.restore();
}
private void drawSelf(@NonNull RendererContext rendererContext) {
if (renderer == null) return;
renderer.render(rendererContext);
}
private static void drawChildren(@NonNull List<EditorElement> children, @NonNull RendererContext rendererContext) {
for (EditorElement element : children) {
if (element.zOrder >= 0) {
element.draw(rendererContext);
}
}
}
public void addElement(@NonNull EditorElement element) {
children.add(element);
Collections.sort(children, Z_ORDER_COMPARATOR);
}
public Matrix getLocalMatrix() {
return localMatrix;
}
public Matrix getEditorMatrix() {
return editorMatrix;
}
EditorElement findElement(@NonNull EditorElement toFind, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
return findElement(viewMatrix, outInverseModelMatrix, (element, inverseMatrix) -> toFind == element);
}
EditorElement findElementAt(float x, float y, @NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix) {
final float[] dst = new float[2];
final float[] src = { x, y };
return findElement(viewModelMatrix, outInverseModelMatrix, (element, inverseMatrix) -> {
Renderer renderer = element.renderer;
if (renderer == null) return false;
inverseMatrix.mapPoints(dst, src);
return element.flags.isSelectable() && renderer.hitTest(dst[0], dst[1]);
});
}
public EditorElement findElement(@NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix, @NonNull FindElementPredicate predicate) {
temp.set(viewModelMatrix);
temp.preConcat(localMatrix);
temp.preConcat(editorMatrix);
if (temp.invert(tempMatrix)) {
for (int i = children.size() - 1; i >= 0; i--) {
EditorElement elementAt = children.get(i).findElement(temp, outInverseModelMatrix, predicate);
if (elementAt != null) {
return elementAt;
}
}
if (predicate.test(this, tempMatrix)) {
outInverseModelMatrix.set(tempMatrix);
return this;
}
}
return null;
}
public EditorFlags getFlags() {
return flags;
}
int getChildCount() {
return children.size();
}
EditorElement getChild(int i) {
return children.get(i);
}
void forAllInTree(@NonNull PerElementFunction function) {
function.apply(this);
for (EditorElement child : children) {
child.forAllInTree(function);
}
}
void deleteChild(@NonNull EditorElement editorElement, @Nullable Runnable invalidate) {
Iterator<EditorElement> iterator = children.iterator();
while (iterator.hasNext()) {
if (iterator.next() == editorElement) {
iterator.remove();
addDeletedChildFadingOut(editorElement, invalidate);
}
}
}
void addDeletedChildFadingOut(@NonNull EditorElement fromElement, @Nullable Runnable invalidate) {
deletedChildren.add(fromElement);
fromElement.animateFadeOut(invalidate);
}
void animateFadeOut(@Nullable Runnable invalidate) {
alphaAnimation = AlphaAnimation.animate(1, 0, invalidate);
}
void animateFadeIn(@Nullable Runnable invalidate) {
alphaAnimation = AlphaAnimation.animate(0, 1, invalidate);
}
public void animatePartialFadeOut(@Nullable Runnable invalidate) {
alphaAnimation = AlphaAnimation.animate(alphaAnimation.getValue(), 0.5f, invalidate);
}
public void animatePartialFadeIn(@Nullable Runnable invalidate) {
alphaAnimation = AlphaAnimation.animate(alphaAnimation.getValue(), 1f, invalidate);
}
@Nullable EditorElement parentOf(@NonNull EditorElement element) {
if (children.contains(element)) {
return this;
}
for (EditorElement child : children) {
EditorElement parent = child.parentOf(element);
if (parent != null) {
return parent;
}
}
return null;
}
public void singleScalePulse(@Nullable Runnable invalidate) {
Matrix scale = new Matrix();
scale.setScale(1.2f, 1.2f);
animationMatrix = AnimationMatrix.singlePulse(scale, invalidate);
}
public int getZOrder() {
return zOrder;
}
public interface PerElementFunction {
void apply(EditorElement element);
}
public interface FindElementPredicate {
boolean test(EditorElement element, Matrix inverseMatrix);
}
public void commitEditorMatrix() {
if (flags.isEditable()) {
localMatrix.preConcat(editorMatrix);
editorMatrix.reset();
} else {
rollbackEditorMatrix(null);
}
}
void rollbackEditorMatrix(@Nullable Runnable invalidate) {
animateEditorTo(new Matrix(), invalidate);
}
void buildMap(Map<UUID, EditorElement> map) {
map.put(id, this);
for (EditorElement child : children) {
child.buildMap(map);
}
}
void animateFrom(@NonNull Matrix oldMatrix, @Nullable Runnable invalidate) {
Matrix oldMatrixCopy = new Matrix(oldMatrix);
animationMatrix.stop();
animationMatrix.preConcatValueTo(oldMatrixCopy);
animationMatrix = AnimationMatrix.animate(oldMatrixCopy, localMatrix, invalidate);
}
void animateEditorTo(@NonNull Matrix newEditorMatrix, @Nullable Runnable invalidate) {
setMatrixWithAnimation(editorMatrix, newEditorMatrix, invalidate);
}
void animateLocalTo(@NonNull Matrix newLocalMatrix, @Nullable Runnable invalidate) {
setMatrixWithAnimation(localMatrix, newLocalMatrix, invalidate);
}
/**
* @param destination Matrix to change
* @param source Matrix value to set
* @param invalidate Callback to allow animation
*/
private void setMatrixWithAnimation(@NonNull Matrix destination, @NonNull Matrix source, @Nullable Runnable invalidate) {
Matrix old = new Matrix(destination);
animationMatrix.stop();
animationMatrix.preConcatValueTo(old);
destination.set(source);
animationMatrix = AnimationMatrix.animate(old, destination, invalidate);
}
Matrix getLocalMatrixAnimating() {
Matrix matrix = new Matrix(localMatrix);
animationMatrix.preConcatValueTo(matrix);
return matrix;
}
void stopAnimation() {
animationMatrix.stop();
}
public static final Creator<EditorElement> CREATOR = new Creator<EditorElement>() {
@Override
public EditorElement createFromParcel(Parcel in) {
return new EditorElement(in);
}
@Override
public EditorElement[] newArray(int size) {
return new EditorElement[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
ParcelUtils.writeUUID(dest, id);
dest.writeInt(this.flags.asInt());
ParcelUtils.writeMatrix(dest, localMatrix);
dest.writeParcelable(renderer, flags);
dest.writeInt(zOrder);
dest.writeTypedList(children);
}
}

View File

@@ -0,0 +1,435 @@
package org.signal.imageeditor.core.model;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.RectF;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.R;
import org.signal.imageeditor.core.renderers.CropAreaRenderer;
import org.signal.imageeditor.core.renderers.FillRenderer;
import org.signal.imageeditor.core.renderers.InverseFillRenderer;
import org.signal.imageeditor.core.renderers.OvalGuideRenderer;
import org.signal.imageeditor.core.renderers.TrashRenderer;
/**
* Creates and handles a strict EditorElement Hierarchy.
* <p>
* root - always square, contains only temporary zooms for editing. e.g. when the whole editor zooms out for cropping
* |
* |- view - contains persisted adjustments for crops
* | |
* | |- flipRotate - contains persisted adjustments for flip and rotate operations, ensures operations are centered within the current view
* | |
* | |- imageRoot
* | | |- mainImage
* | | |- stickers/drawings/text
* | |
* | |- overlay - always square
* | | |- imageCrop - a crop to match the aspect of the main image
* | | | |- cropEditorElement - user crop, not always square, but upright, the area of the view
* | | | | | All children do not move/scale or rotate.
* | | | | |- blackout
* | | | | |- fade
* | | | | |- thumbs
* | | | | | |- Center left thumb
* | | | | | |- Center right thumb
* | | | | | |- Top center thumb
* | | | | | |- Bottom center thumb
* | | | | | |- Top left thumb
* | | | | | |- Top right thumb
* | | | | | |- Bottom left thumb
* | | | | | |- Bottom right thumb
*/
final class EditorElementHierarchy {
static @NonNull EditorElementHierarchy create() {
return new EditorElementHierarchy(createRoot(CropStyle.RECTANGLE));
}
static @NonNull EditorElementHierarchy createForCircleEditing() {
return new EditorElementHierarchy(createRoot(CropStyle.CIRCLE));
}
static @NonNull EditorElementHierarchy createForPinchAndPanCropping() {
return new EditorElementHierarchy(createRoot(CropStyle.PINCH_AND_PAN));
}
static @NonNull EditorElementHierarchy create(@NonNull EditorElement root) {
return new EditorElementHierarchy(root);
}
private final EditorElement root;
private final EditorElement view;
private final EditorElement flipRotate;
private final EditorElement imageRoot;
private final EditorElement overlay;
private final EditorElement imageCrop;
private final EditorElement cropEditorElement;
private final EditorElement blackout;
private final EditorElement fade;
private final EditorElement trash;
private final EditorElement thumbs;
private EditorElementHierarchy(@NonNull EditorElement root) {
this.root = root;
this.view = this.root.getChild(0);
this.flipRotate = this.view.getChild(0);
this.imageRoot = this.flipRotate.getChild(0);
this.overlay = this.flipRotate.getChild(1);
this.imageCrop = this.overlay.getChild(0);
this.cropEditorElement = this.imageCrop.getChild(0);
this.blackout = this.cropEditorElement.getChild(0);
this.thumbs = this.cropEditorElement.getChild(1);
this.fade = this.cropEditorElement.getChild(2);
this.trash = this.cropEditorElement.getChild(3);
}
private enum CropStyle {
/**
* A rectangular overlay with 8 thumbs, corners and edges.
*/
RECTANGLE,
/**
* Cropping with a circular template overlay with Corner thumbs only.
*/
CIRCLE,
/**
* No overlay and no thumbs. Cropping achieved through pinching and panning.
*/
PINCH_AND_PAN
}
private static @NonNull EditorElement createRoot(@NonNull CropStyle cropStyle) {
EditorElement root = new EditorElement(null);
EditorElement imageRoot = new EditorElement(null);
root.addElement(imageRoot);
EditorElement flipRotate = new EditorElement(null);
imageRoot.addElement(flipRotate);
EditorElement image = new EditorElement(null);
flipRotate.addElement(image);
EditorElement overlay = new EditorElement(null);
flipRotate.addElement(overlay);
EditorElement imageCrop = new EditorElement(null);
overlay.addElement(imageCrop);
boolean renderCenterThumbs = cropStyle == CropStyle.RECTANGLE;
EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color, renderCenterThumbs));
cropEditorElement.getFlags()
.setRotateLocked(true)
.setAspectLocked(true)
.setSelectable(false)
.setVisible(false)
.persist();
imageCrop.addElement(cropEditorElement);
EditorElement fade = new EditorElement(new FillRenderer(0x66000000), EditorModel.Z_FADE);
fade.getFlags()
.setSelectable(false)
.setEditable(false)
.setVisible(false)
.persist();
cropEditorElement.addElement(fade);
EditorElement trash = new EditorElement(new TrashRenderer(), EditorModel.Z_TRASH);
trash.getFlags()
.setSelectable(false)
.setEditable(false)
.setVisible(false)
.persist();
cropEditorElement.addElement(trash);
EditorElement blackout = new EditorElement(new InverseFillRenderer(0xff000000));
blackout.getFlags()
.setSelectable(false)
.setEditable(false)
.persist();
cropEditorElement.addElement(blackout);
if (cropStyle == CropStyle.PINCH_AND_PAN) {
cropEditorElement.addElement(new EditorElement(null));
} else {
cropEditorElement.addElement(createThumbs(cropEditorElement, renderCenterThumbs));
if (cropStyle == CropStyle.CIRCLE) {
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, boolean centerThumbs) {
EditorElement thumbs = new EditorElement(null);
thumbs.getFlags()
.setChildrenVisible(false)
.setSelectable(false)
.setVisible(false)
.persist();
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_LEFT));
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_RIGHT));
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_LEFT));
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_RIGHT));
return thumbs;
}
private static @NonNull EditorElement newThumb(@NonNull EditorElement toControl, @NonNull ThumbRenderer.ControlPoint controlPoint) {
EditorElement element = new EditorElement(new CropThumbRenderer(controlPoint, toControl.getId()));
element.getFlags()
.setSelectable(false)
.persist();
element.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY());
return element;
}
EditorElement getRoot() {
return root;
}
EditorElement getImageRoot() {
return imageRoot;
}
EditorElement getTrash() {
return trash;
}
/**
* The main image, null if not yet set.
*/
@Nullable EditorElement getMainImage() {
return imageRoot.getChildCount() > 0 ? imageRoot.getChild(0) : null;
}
EditorElement getCropEditorElement() {
return cropEditorElement;
}
EditorElement getImageCrop() {
return imageCrop;
}
EditorElement getOverlay() {
return overlay;
}
EditorElement getFlipRotate() {
return flipRotate;
}
void addFade(@NonNull Runnable invalidate) {
fade.getFlags()
.setVisible(true)
.persist();
invalidate.run();
}
void removeFade(@NonNull Runnable invalidate) {
fade.getFlags()
.setVisible(false)
.persist();
invalidate.run();
}
/**
* @param scaleIn Use 1 for no scale in, use less than 1 and it will zoom the image out
* so user can see more of the surrounding image while cropping.
*/
void startCrop(@NonNull Runnable invalidate, float scaleIn) {
Matrix editor = new Matrix();
editor.postScale(scaleIn, scaleIn);
root.animateEditorTo(editor, invalidate);
cropEditorElement.getFlags()
.setVisible(true);
blackout.getFlags()
.setVisible(false);
thumbs.getFlags()
.setChildrenVisible(true);
thumbs.forAllInTree(element -> element.getFlags().setSelectable(true));
imageRoot.forAllInTree(element -> element.getFlags().setSelectable(false));
EditorElement mainImage = getMainImage();
if (mainImage != null) {
mainImage.getFlags().setSelectable(true);
}
invalidate.run();
}
void doneCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
updateViewToCrop(visibleViewPort, invalidate);
root.rollbackEditorMatrix(invalidate);
root.forAllInTree(element -> element.getFlags().reset());
}
void updateViewToCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
RectF dst = new RectF();
getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS);
Matrix temp = new Matrix();
temp.setRectToRect(dst, visibleViewPort, Matrix.ScaleToFit.CENTER);
view.animateLocalTo(temp, invalidate);
}
private @NonNull Matrix getCropFinalMatrix() {
Matrix matrix = new Matrix(flipRotate.getLocalMatrix());
matrix.preConcat(imageCrop.getLocalMatrix());
matrix.preConcat(cropEditorElement.getLocalMatrix());
return matrix;
}
/**
* Returns a matrix that maps points from the crop on to the visible image.
* <p>
* i.e. if a mapped point is in bounds, then the point is on the visible image.
*/
@Nullable Matrix imageMatrixRelativeToCrop() {
EditorElement mainImage = getMainImage();
if (mainImage == null) return null;
Matrix matrix1 = new Matrix(imageCrop.getLocalMatrix());
matrix1.preConcat(cropEditorElement.getLocalMatrix());
matrix1.preConcat(cropEditorElement.getEditorMatrix());
Matrix matrix2 = new Matrix(mainImage.getLocalMatrix());
matrix2.preConcat(mainImage.getEditorMatrix());
matrix2.preConcat(imageCrop.getLocalMatrix());
Matrix inverse = new Matrix();
matrix2.invert(inverse);
inverse.preConcat(matrix1);
return inverse;
}
void dragDropRelease(@NonNull RectF visibleViewPort, @NonNull Runnable invalidate) {
if (cropEditorElement.getFlags().isVisible()) {
updateViewToCrop(visibleViewPort, invalidate);
}
}
RectF getCropRect() {
RectF dst = new RectF();
getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS);
return dst;
}
void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
Matrix newLocal = new Matrix(flipRotate.getLocalMatrix());
if (degrees != 0) {
newLocal.postRotate(degrees);
}
newLocal.postScale(scaleX, scaleY);
flipRotate.animateLocalTo(newLocal, invalidate);
updateViewToCrop(visibleViewPort, invalidate);
}
/**
* The full matrix for the {@link #getMainImage()} from {@link #root} down.
*/
Matrix getMainImageFullMatrix() {
Matrix matrix = new Matrix();
matrix.preConcat(view.getLocalMatrix());
matrix.preConcat(getMainImageFullMatrixFromFlipRotate());
return matrix;
}
/**
* The full matrix for the {@link #getMainImage()} from {@link #flipRotate} down.
*/
Matrix getMainImageFullMatrixFromFlipRotate() {
Matrix matrix = new Matrix();
matrix.preConcat(flipRotate.getLocalMatrix());
matrix.preConcat(imageRoot.getLocalMatrix());
EditorElement mainImage = getMainImage();
if (mainImage != null) {
matrix.preConcat(mainImage.getLocalMatrix());
}
return matrix;
}
/**
* Calculates the exact output size based upon the crops/rotates and zooms in the hierarchy.
*
* @param inputSize Main image size
* @return Size after applying all zooms/rotates and crops
*/
PointF getOutputSize(@NonNull Point inputSize) {
Matrix matrix = new Matrix();
matrix.preConcat(flipRotate.getLocalMatrix());
matrix.preConcat(cropEditorElement.getLocalMatrix());
matrix.preConcat(cropEditorElement.getEditorMatrix());
EditorElement mainImage = getMainImage();
if (mainImage != null) {
float xScale = 1f / (xScale(mainImage.getLocalMatrix()) * xScale(mainImage.getEditorMatrix()));
matrix.preScale(xScale, xScale);
}
float[] dst = new float[4];
matrix.mapPoints(dst, new float[]{ 0, 0, inputSize.x, inputSize.y });
float widthF = Math.abs(dst[0] - dst[2]);
float heightF = Math.abs(dst[1] - dst[3]);
return new PointF(widthF, heightF);
}
/**
* Extract the x scale from a matrix, which is the length of the first column.
*/
static float xScale(@NonNull Matrix matrix) {
float[] values = new float[9];
matrix.getValues(values);
return (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]);
}
}

View File

@@ -0,0 +1,132 @@
package org.signal.imageeditor.core.model;
import androidx.annotation.NonNull;
/**
* Flags for an {@link EditorElement}.
* <p>
* Values you set are not persisted unless you call {@link #persist()}.
* <p>
* This allows temporary state for editing and an easy way to revert to the persisted state via {@link #reset()}.
*/
public final class EditorFlags {
private static final int ASPECT_LOCK = 1;
private static final int ROTATE_LOCK = 2;
private static final int SELECTABLE = 4;
private static final int VISIBLE = 8;
private static final int CHILDREN_VISIBLE = 16;
private static final int EDITABLE = 32;
private int flags;
private int markedFlags;
private int persistedFlags;
EditorFlags() {
this(ASPECT_LOCK | SELECTABLE | VISIBLE | CHILDREN_VISIBLE | EDITABLE);
}
EditorFlags(int flags) {
this.flags = flags;
this.persistedFlags = flags;
}
public EditorFlags setRotateLocked(boolean rotateLocked) {
setFlag(ROTATE_LOCK, rotateLocked);
return this;
}
public boolean isRotateLocked() {
return isFlagSet(ROTATE_LOCK);
}
public EditorFlags setAspectLocked(boolean aspectLocked) {
setFlag(ASPECT_LOCK, aspectLocked);
return this;
}
public boolean isAspectLocked() {
return isFlagSet(ASPECT_LOCK);
}
public EditorFlags setSelectable(boolean selectable) {
setFlag(SELECTABLE, selectable);
return this;
}
public boolean isSelectable() {
return isFlagSet(SELECTABLE);
}
public EditorFlags setEditable(boolean canEdit) {
setFlag(EDITABLE, canEdit);
return this;
}
public boolean isEditable() {
return isFlagSet(EDITABLE);
}
public EditorFlags setVisible(boolean visible) {
setFlag(VISIBLE, visible);
return this;
}
public boolean isVisible() {
return isFlagSet(VISIBLE);
}
public EditorFlags setChildrenVisible(boolean childrenVisible) {
setFlag(CHILDREN_VISIBLE, childrenVisible);
return this;
}
public boolean isChildrenVisible() {
return isFlagSet(CHILDREN_VISIBLE);
}
private void setFlag(int flag, boolean set) {
if (set) {
this.flags |= flag;
} else {
this.flags &= ~flag;
}
}
private boolean isFlagSet(int flag) {
return (flags & flag) != 0;
}
int asInt() {
return persistedFlags;
}
int getCurrentState() {
return flags;
}
public void persist() {
persistedFlags = flags;
}
public void reset() {
restoreState(persistedFlags);
}
void restoreState(int flags) {
this.flags = flags;
}
void mark() {
markedFlags = flags;
}
void restore() {
flags = markedFlags;
}
public void set(@NonNull EditorFlags from) {
this.persistedFlags = from.persistedFlags;
this.flags = from.flags;
}
}

View File

@@ -0,0 +1,963 @@
package org.signal.imageeditor.core.model;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.RectF;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.ColorableRenderer;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
import org.signal.imageeditor.core.UndoRedoStackListener;
import org.signal.imageeditor.core.renderers.FaceBlurRenderer;
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Contains a reference to the root {@link EditorElement}, maintains undo and redo stacks and has a
* reference to the {@link EditorElementHierarchy}.
* <p>
* As such it is the entry point for all operations that change the image.
*/
public final class EditorModel implements Parcelable, RendererContext.Ready {
public static final int Z_MASK = -1;
public static final int Z_DRAWING = 0;
public static final int Z_STICKERS = 0;
public static final int Z_FADE = 1;
public static final int Z_TEXT = 2;
public static final int Z_TRASH = 3;
private static final Runnable NULL_RUNNABLE = () -> {
};
private static final int MINIMUM_OUTPUT_WIDTH = 1024;
private static final int MINIMUM_CROP_PIXEL_COUNT = 100;
private static final Point MINIMUM_RATIO = new Point(15, 1);
@NonNull
private Runnable invalidate = NULL_RUNNABLE;
private UndoRedoStackListener undoRedoStackListener;
private final UndoRedoStacks undoRedoStacks;
private final UndoRedoStacks cropUndoRedoStacks;
private final InBoundsMemory inBoundsMemory = new InBoundsMemory();
private EditorElementHierarchy editorElementHierarchy;
private final RectF visibleViewPort = new RectF();
private final Point size;
private final EditingPurpose editingPurpose;
private float fixedRatio;
private enum EditingPurpose {
IMAGE,
AVATAR_CAPTURE,
AVATAR_EDIT,
WALLPAPER
}
private EditorModel(@NonNull Parcel in) {
ClassLoader classLoader = getClass().getClassLoader();
this.editingPurpose = EditingPurpose.values()[in.readInt()];
this.fixedRatio = in.readFloat();
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(@NonNull EditingPurpose editingPurpose, float fixedRatio, @NonNull EditorElementHierarchy editorElementHierarchy) {
this.editingPurpose = editingPurpose;
this.fixedRatio = fixedRatio;
this.size = new Point(1024, 1024);
this.editorElementHierarchy = editorElementHierarchy;
this.undoRedoStacks = new UndoRedoStacks(50);
this.cropUndoRedoStacks = new UndoRedoStacks(50);
}
public static EditorModel create() {
EditorModel model = new EditorModel(EditingPurpose.IMAGE, 0, EditorElementHierarchy.create());
model.setCropAspectLock(false);
return model;
}
public static EditorModel createForAvatarCapture() {
EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_CAPTURE, 1, EditorElementHierarchy.createForCircleEditing());
editorModel.setCropAspectLock(true);
return editorModel;
}
public static EditorModel createForAvatarEdit() {
EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_EDIT, 1, EditorElementHierarchy.createForCircleEditing());
editorModel.setCropAspectLock(true);
return editorModel;
}
public static EditorModel createForWallpaperEditing(float fixedRatio) {
EditorModel editorModel = new EditorModel(EditingPurpose.WALLPAPER, fixedRatio, EditorElementHierarchy.createForPinchAndPanCropping());
editorModel.setCropAspectLock(true);
return editorModel;
}
public void setInvalidate(@Nullable Runnable invalidate) {
this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE;
}
public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) {
this.undoRedoStackListener = undoRedoStackListener;
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
}
/**
* Renders tree with the following matrix:
* <p>
* viewModelMatrix * matrix * editorMatrix
* <p>
* Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix
*
* @param rendererContext Canvas to draw on to.
* @param renderOnTop This element will appear on top of the overlay.
*/
public void draw(@NonNull RendererContext rendererContext, @Nullable EditorElement renderOnTop) {
EditorElement root = editorElementHierarchy.getRoot();
if (renderOnTop != null) {
root.forAllInTree(element -> element.getFlags().mark());
renderOnTop.getFlags().setVisible(false);
}
// pass 1
root.draw(rendererContext);
if (renderOnTop != null) {
// hide all
try {
root.forAllInTree(element -> element.getFlags().setVisible(renderOnTop == element));
// pass 2
root.draw(rendererContext);
} finally {
root.forAllInTree(element -> element.getFlags().restore());
}
}
}
public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
Matrix inverse = new Matrix();
if (findElement(element, viewMatrix, inverse)) {
return inverse;
}
return null;
}
private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
Matrix inverse = findElementInverseMatrix(element, viewMatrix);
if (inverse != null) {
Matrix regular = new Matrix();
inverse.invert(regular);
return regular;
}
return null;
}
public EditorElement findElementAtPoint(@NonNull PointF point, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix);
}
public boolean checkTrashIntersectsPoint(@NonNull PointF point) {
EditorElement trash = editorElementHierarchy.getTrash();
if (trash.getFlags().isVisible()) {
trash.getFlags()
.setSelectable(true)
.persist();
boolean isIntersecting = trash.findElementAt(point.x, point.y, new Matrix(), new Matrix()) != null;
trash.getFlags()
.setSelectable(false)
.persist();
return isIntersecting;
} else {
return false;
}
}
private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element;
}
public void pushUndoPoint() {
boolean cropping = isCropping();
if (cropping && !currentCropIsAcceptable()) {
return;
}
getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot());
}
public void clearUndoStack() {
EditorElement root = editorElementHierarchy.getRoot();
EditorElement original = root;
boolean cropping = isCropping();
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
boolean didPop = false;
while (stacks.canUndo(root)) {
final EditorElement oldRootElement = root;
final EditorElement popped = stacks.getUndoStack().pop(oldRootElement);
if (popped != null) {
didPop = true;
editorElementHierarchy = EditorElementHierarchy.create(popped);
stacks.getRedoStack().tryPush(oldRootElement);
} else {
break;
}
root = editorElementHierarchy.getRoot();
}
if (didPop) {
restoreStateWithAnimations(original, editorElementHierarchy.getRoot(), invalidate, cropping);
invalidate.run();
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
}
updateUndoRedoAvailableState(stacks);
}
public void undo() {
boolean cropping = isCropping();
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
undoRedo(stacks.getUndoStack(), stacks.getRedoStack(), cropping);
updateUndoRedoAvailableState(stacks);
}
public void redo() {
boolean cropping = isCropping();
UndoRedoStacks stacks = getActiveUndoRedoStacks(cropping);
undoRedo(stacks.getRedoStack(), stacks.getUndoStack(), cropping);
updateUndoRedoAvailableState(stacks);
}
private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack, boolean keepEditorState) {
final EditorElement oldRootElement = editorElementHierarchy.getRoot();
final EditorElement popped = fromStack.pop(oldRootElement);
if (popped != null) {
editorElementHierarchy = EditorElementHierarchy.create(popped);
toStack.tryPush(oldRootElement);
restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, keepEditorState);
invalidate.run();
// re-zoom image root as the view port might be different now
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
}
}
private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate, boolean keepEditorState) {
Map<UUID, EditorElement> fromMap = getElementMap(fromRootElement);
Map<UUID, EditorElement> toMap = getElementMap(toRootElement);
for (EditorElement fromElement : fromMap.values()) {
fromElement.stopAnimation();
EditorElement toElement = toMap.get(fromElement.getId());
if (toElement != null) {
toElement.animateFrom(fromElement.getLocalMatrixAnimating(), onInvalidate);
if (keepEditorState) {
toElement.getEditorMatrix().set(fromElement.getEditorMatrix());
toElement.getFlags().set(fromElement.getFlags());
}
} else {
// element is removed
EditorElement parentFrom = fromRootElement.parentOf(fromElement);
if (parentFrom != null) {
EditorElement toParent = toMap.get(parentFrom.getId());
if (toParent != null) {
toParent.addDeletedChildFadingOut(fromElement, onInvalidate);
}
}
}
}
for (EditorElement toElement : toMap.values()) {
if (!fromMap.containsKey(toElement.getId())) {
// new item
toElement.animateFadeIn(onInvalidate);
}
}
}
private void updateUndoRedoAvailableState(UndoRedoStacks currentStack) {
if (undoRedoStackListener == null) return;
EditorElement root = editorElementHierarchy.getRoot();
undoRedoStackListener.onAvailabilityChanged(currentStack.canUndo(root), currentStack.canRedo(root));
}
private static Map<UUID, EditorElement> getElementMap(@NonNull EditorElement element) {
final Map<UUID, EditorElement> result = new HashMap<>();
element.buildMap(result);
return result;
}
public void addFade() {
editorElementHierarchy.addFade(invalidate);
}
public void removeFade() {
editorElementHierarchy.removeFade(invalidate);
}
public void startCrop() {
float scaleIn = editingPurpose == EditingPurpose.WALLPAPER ? 1 : 0.8f;
pushUndoPoint();
cropUndoRedoStacks.clear(editorElementHierarchy.getRoot());
editorElementHierarchy.startCrop(invalidate, scaleIn);
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
updateUndoRedoAvailableState(cropUndoRedoStacks);
}
public void doneCrop() {
editorElementHierarchy.doneCrop(visibleViewPort, invalidate);
updateUndoRedoAvailableState(undoRedoStacks);
}
public void setCropAspectLock(boolean locked) {
EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags();
int currentState = flags.setAspectLocked(locked).getCurrentState();
flags.reset();
flags.setAspectLocked(locked)
.persist();
flags.restoreState(currentState);
}
public boolean isCropAspectLocked() {
return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked();
}
public void postEdit(boolean allowScaleToRepairCrop) {
boolean cropping = isCropping();
if (cropping) {
ensureFitsBounds(allowScaleToRepairCrop);
}
updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping));
}
/**
* @param cropping Set to true if cropping is underway.
* @return The correct stack for the mode of operation.
*/
private UndoRedoStacks getActiveUndoRedoStacks(boolean cropping) {
return cropping ? cropUndoRedoStacks : undoRedoStacks;
}
private void ensureFitsBounds(boolean allowScaleToRepairCrop) {
EditorElement mainImage = editorElementHierarchy.getMainImage();
if (mainImage == null) return;
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
if (!currentCropIsAcceptable()) {
if (allowScaleToRepairCrop) {
if (!tryToScaleToFit(cropEditorElement, 0.9f)) {
tryToScaleToFit(mainImage, 2f);
}
} else {
tryToFixTranslationOutOfBounds(mainImage, inBoundsMemory.getLastKnownGoodMainImageMatrix());
}
if (!currentCropIsAcceptable()) {
inBoundsMemory.restore(mainImage, cropEditorElement, invalidate);
} else {
inBoundsMemory.push(mainImage, cropEditorElement);
}
}
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
}
/**
* Attempts to scale the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
* <p>
* Does not respect minimum scale, so does need a further check to {@link #currentCropIsAcceptable} afterwards.
*
* @param element The element to be scaled. If successful, it will be animated to the correct position.
* @param scaleAtMost The amount of scale to apply at most. Use < 1 for the crop, and > 1 for the image.
* @return true if successfully scaled the element. false if the element was left unchanged.
*/
private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) {
return Bisect.bisectToTest(element,
1,
scaleAtMost,
this::cropIsWithinMainImageBounds,
(matrix, scale) -> matrix.preScale(scale, scale),
invalidate);
}
/**
* Attempts to translate the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
* If you supply both x and y, it will attempt to find a fit on the diagonal with vector x, y.
*
* @param element The element to be translated. If successful, it will be animated to the correct position.
* @param translateXAtMost The maximum translation to apply in the x axis.
* @param translateYAtMost The maximum translation to apply in the y axis.
* @return a matrix if successfully translated the element. null if the element unable to be translated to fit.
*/
private Matrix tryToTranslateToFit(@NonNull EditorElement element, float translateXAtMost, float translateYAtMost) {
return Bisect.bisectToTest(element,
0,
1,
this::cropIsWithinMainImageBounds,
(matrix, factor) -> matrix.postTranslate(factor * translateXAtMost, factor * translateYAtMost));
}
/**
* Tries to fix an element that is out of bounds by adjusting it's translation.
*
* @param element Element to move.
* @param lastKnownGoodPosition Last known good position of element.
* @return true iff fixed the element.
*/
private boolean tryToFixTranslationOutOfBounds(@NonNull EditorElement element, @NonNull Matrix lastKnownGoodPosition) {
final Matrix elementMatrix = element.getLocalMatrix();
final Matrix original = new Matrix(elementMatrix);
final float[] current = new float[9];
final float[] lastGood = new float[9];
Matrix matrix;
elementMatrix.getValues(current);
lastKnownGoodPosition.getValues(lastGood);
final float xTranslate = current[2] - lastGood[2];
final float yTranslate = current[5] - lastGood[5];
if (Math.abs(xTranslate) < Bisect.ACCURACY && Math.abs(yTranslate) < Bisect.ACCURACY) {
return false;
}
float pass1X;
float pass1Y;
float pass2X;
float pass2Y;
// try the fix by the smallest user translation first
if (Math.abs(xTranslate) < Math.abs(yTranslate)) {
// try to bisect along x
pass1X = -xTranslate;
pass1Y = 0;
// then y
pass2X = 0;
pass2Y = -yTranslate;
} else {
// try to bisect along y
pass1X = 0;
pass1Y = -yTranslate;
// then x
pass2X = -xTranslate;
pass2Y = 0;
}
matrix = tryToTranslateToFit(element, pass1X, pass1Y);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
// apply pass 1 fully
elementMatrix.postTranslate(pass1X, pass1Y);
matrix = tryToTranslateToFit(element, pass2X, pass2Y);
elementMatrix.set(original);
if (matrix != null) {
element.animateLocalTo(matrix, invalidate);
return true;
}
return false;
}
public void dragDropRelease() {
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
}
/**
* Pixel count must be no smaller than {@link #MINIMUM_CROP_PIXEL_COUNT} (unless its original size was less than that)
* and all points must be within the bounds.
*/
private boolean currentCropIsAcceptable() {
Point outputSize = getOutputSize();
int outputPixelCount = outputSize.x * outputSize.y;
int minimumPixelCount = Math.min(size.x * size.y, MINIMUM_CROP_PIXEL_COUNT);
Point thinnestRatio = MINIMUM_RATIO;
if (compareRatios(size, thinnestRatio) < 0) {
// original is narrower than the thinnestRatio
thinnestRatio = size;
}
return compareRatios(outputSize, thinnestRatio) >= 0 &&
outputPixelCount >= minimumPixelCount &&
cropIsWithinMainImageBounds();
}
/**
* -1 iff a is a narrower ratio than b.
* +1 iff a is a squarer ratio than b.
* 0 if the ratios are the same.
*/
private static int compareRatios(@NonNull Point a, @NonNull Point b) {
int smallA = Math.min(a.x, a.y);
int largeA = Math.max(a.x, a.y);
int smallB = Math.min(b.x, b.y);
int largeB = Math.max(b.x, b.y);
return Integer.compare(smallA * largeB, smallB * largeA);
}
/**
* @return true if and only if the current crop rect is fully in the bounds.
*/
private boolean cropIsWithinMainImageBounds() {
return Bounds.boundsRemainInBounds(editorElementHierarchy.imageMatrixRelativeToCrop());
}
/**
* Called as edits are underway.
*/
public void moving(@NonNull EditorElement editorElement) {
if (!isCropping()) return;
EditorElement mainImage = editorElementHierarchy.getMainImage();
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
if (editorElement == mainImage || editorElement == cropEditorElement) {
if (currentCropIsAcceptable()) {
inBoundsMemory.push(mainImage, cropEditorElement);
}
}
}
public void setVisibleViewPort(@NonNull RectF visibleViewPort) {
this.visibleViewPort.set(visibleViewPort);
this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
}
public Set<Integer> getUniqueColorsIgnoringAlpha() {
final Set<Integer> colors = new LinkedHashSet<>();
editorElementHierarchy.getRoot().forAllInTree(element -> {
Renderer renderer = element.getRenderer();
if (renderer instanceof ColorableRenderer) {
colors.add(((ColorableRenderer) renderer).getColor() | 0xff000000);
}
});
return colors;
}
public static final Creator<EditorModel> CREATOR = new Creator<EditorModel>() {
@Override
public EditorModel createFromParcel(Parcel in) {
return new EditorModel(in);
}
@Override
public EditorModel[] newArray(int size) {
return new EditorModel[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(editingPurpose.ordinal());
dest.writeFloat(fixedRatio);
dest.writeInt(size.x);
dest.writeInt(size.y);
dest.writeParcelable(editorElementHierarchy.getRoot(), flags);
dest.writeParcelable(undoRedoStacks, flags);
dest.writeParcelable(cropUndoRedoStacks, flags);
}
/**
* Blocking render of the model.
*/
@WorkerThread
public @NonNull Bitmap render(@NonNull Context context) {
return render(context, null);
}
/**
* Blocking render of the model.
*/
@WorkerThread
public @NonNull Bitmap render(@NonNull Context context, @Nullable Point size) {
EditorElement image = editorElementHierarchy.getFlipRotate();
RectF cropRect = editorElementHierarchy.getCropRect();
Point outputSize = size != null ? size : getOutputSize();
Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888);
try {
Canvas canvas = new Canvas(bitmap);
RendererContext rendererContext = new RendererContext(context, canvas, RendererContext.Ready.NULL, RendererContext.Invalidate.NULL);
RectF bitmapArea = new RectF();
bitmapArea.right = bitmap.getWidth();
bitmapArea.bottom = bitmap.getHeight();
Matrix viewMatrix = new Matrix();
viewMatrix.setRectToRect(cropRect, bitmapArea, Matrix.ScaleToFit.FILL);
rendererContext.setIsEditing(false);
rendererContext.setBlockingLoad(true);
EditorElement overlay = editorElementHierarchy.getOverlay();
overlay.getFlags().setVisible(false).setChildrenVisible(false);
try {
rendererContext.canvasMatrix.initial(viewMatrix);
image.draw(rendererContext);
} finally {
overlay.getFlags().reset();
}
} catch (Exception e) {
bitmap.recycle();
throw e;
}
return bitmap;
}
@NonNull
private Point getOutputSize() {
PointF outputSize = editorElementHierarchy.getOutputSize(size);
int width = (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x);
int height = (int) (width * outputSize.y / outputSize.x);
return new Point(width, height);
}
@NonNull
public Point getOutputSizeMaxWidth(int maxDimension) {
PointF outputSize = editorElementHierarchy.getOutputSize(size);
int width = Math.min(maxDimension, (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x));
int height = (int) (width * outputSize.y / outputSize.x);
if (height > maxDimension) {
height = maxDimension;
width = (int) (height * outputSize.x / outputSize.y);
}
return new Point(width, height);
}
@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();
this.size.set(size.x, size.y);
if (imageCropMatrix.isIdentity()) {
imageCropMatrix.set(cropMatrix);
if (editingPurpose == EditingPurpose.AVATAR_CAPTURE || editingPurpose == EditingPurpose.WALLPAPER || editingPurpose == EditingPurpose.AVATAR_EDIT) {
Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix();
if (size.x > size.y) {
userCropMatrix.setScale(fixedRatio * 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());
}
switch (editingPurpose) {
case AVATAR_CAPTURE: {
startCrop();
break;
}
case WALLPAPER: {
setFixedRatio(fixedRatio);
startCrop();
break;
}
default:
break;
}
}
}
}
public void setFixedRatio(float r) {
fixedRatio = r;
Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix();
float w = size.x;
float h = size.y;
float imageRatio = w / h;
if (imageRatio > r) {
userCropMatrix.setScale(r / imageRatio, 1f);
} else {
userCropMatrix.setScale(1f, imageRatio / r);
}
editorElementHierarchy.doneCrop(visibleViewPort, null);
startCrop();
}
private boolean isRendererOfMainImage(@NonNull Renderer renderer) {
EditorElement mainImage = editorElementHierarchy.getMainImage();
Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null;
return mainImageRenderer == renderer;
}
/**
* Add a new {@link EditorElement} centered in the current visible crop area.
*
* @param element New element to add.
* @param scale Initial scale for new element.
*/
public void addElementCentered(@NonNull EditorElement element, float scale) {
Matrix localMatrix = element.getLocalMatrix();
editorElementHierarchy.getMainImageFullMatrix().invert(localMatrix);
localMatrix.preScale(scale, scale);
addElement(element);
}
/**
* Add an element to the main image, or if there is no main image, make the new element the main image.
*
* @param element New element to add.
*/
public void addElement(@NonNull EditorElement element) {
pushUndoPoint();
addElementWithoutPushUndo(element);
}
public void addElementWithoutPushUndo(@NonNull EditorElement element) {
EditorElement mainImage = editorElementHierarchy.getMainImage();
EditorElement parent = mainImage != null ? mainImage : editorElementHierarchy.getImageRoot();
parent.addElement(element);
if (parent != mainImage) {
undoRedoStacks.clear(editorElementHierarchy.getRoot());
}
updateUndoRedoAvailableState(undoRedoStacks);
}
public void clearFaceRenderers() {
EditorElement mainImage = editorElementHierarchy.getMainImage();
if (mainImage != null) {
boolean hasPushedUndo = false;
for (int i = mainImage.getChildCount() - 1; i >= 0; i--) {
if (mainImage.getChild(i).getRenderer() instanceof FaceBlurRenderer) {
if (!hasPushedUndo) {
pushUndoPoint();
hasPushedUndo = true;
}
mainImage.deleteChild(mainImage.getChild(i), invalidate);
}
}
}
}
public boolean hasFaceRenderer() {
EditorElement mainImage = editorElementHierarchy.getMainImage();
if (mainImage != null) {
for (int i = mainImage.getChildCount() - 1; i >= 0; i--) {
if (mainImage.getChild(i).getRenderer() instanceof FaceBlurRenderer) {
return true;
}
}
}
return false;
}
public boolean isChanged() {
return undoRedoStacks.isChanged(editorElementHierarchy.getRoot());
}
public RectF findCropRelativeToRoot() {
return findCropRelativeTo(editorElementHierarchy.getRoot());
}
RectF findCropRelativeTo(EditorElement element) {
return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element);
}
RectF findRelativeBounds(EditorElement from, EditorElement to) {
Matrix relative = findRelativeMatrix(from, to);
RectF dst = new RectF(Bounds.FULL_BOUNDS);
if (relative != null) {
relative.mapRect(dst, Bounds.FULL_BOUNDS);
}
return dst;
}
/**
* Returns a matrix that maps points in the {@param from} element in to points in the {@param to} element.
*
* @param from
* @param to
* @return
*/
@Nullable Matrix findRelativeMatrix(@NonNull EditorElement from, @NonNull EditorElement to) {
Matrix matrix = findElementInverseMatrix(to, new Matrix());
Matrix outOf = findElementMatrix(from, new Matrix());
if (outOf != null && matrix != null) {
matrix.preConcat(outOf);
return matrix;
}
return null;
}
public void rotate90clockwise() {
flipRotate(90, 1, 1);
}
public void rotate90anticlockwise() {
flipRotate(-90, 1, 1);
}
public void flipHorizontal() {
flipRotate(0, -1, 1);
}
public void flipVertical() {
flipRotate(0, 1, -1);
}
private void flipRotate(int degrees, int scaleX, int scaleY) {
pushUndoPoint();
editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate);
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
}
public EditorElement getRoot() {
return editorElementHierarchy.getRoot();
}
public EditorElement getTrash() {
return editorElementHierarchy.getTrash();
}
public @Nullable EditorElement getMainImage() {
return editorElementHierarchy.getMainImage();
}
public void delete(@NonNull EditorElement editorElement) {
editorElementHierarchy.getImageRoot().forAllInTree(element -> element.deleteChild(editorElement, invalidate));
}
public @Nullable EditorElement findById(@NonNull UUID uuid) {
return getElementMap(getRoot()).get(uuid);
}
/**
* Changes the temporary view so that the text element is centered in it.
*
* @param entity Entity to center on.
* @param textRenderer The text renderer, which can make additional adjustments to the zoom matrix
* to leave space for the keyboard for example.
*/
public void zoomToTextElement(@NonNull EditorElement entity, @NonNull MultiLineTextRenderer textRenderer) {
Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix());
if (elementInverseMatrix != null) {
EditorElement root = editorElementHierarchy.getRoot();
elementInverseMatrix.preConcat(root.getEditorMatrix());
textRenderer.applyRecommendedEditorMatrix(elementInverseMatrix);
root.animateEditorTo(elementInverseMatrix, invalidate);
}
}
public void zoomOut() {
editorElementHierarchy.getRoot().rollbackEditorMatrix(invalidate);
}
public void indicateSelected(@NonNull EditorElement selected) {
selected.singleScalePulse(invalidate);
}
public boolean isCropping() {
return editorElementHierarchy.getCropEditorElement().getFlags().isVisible();
}
/**
* Returns a matrix that maps bounds to the crop area.
*/
public Matrix getInverseCropPosition() {
Matrix matrix = new Matrix();
matrix.set(findRelativeMatrix(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()));
matrix.postConcat(editorElementHierarchy.getFlipRotate().getLocalMatrix());
Matrix positionRelativeToCrop = new Matrix();
matrix.invert(positionRelativeToCrop);
return positionRelativeToCrop;
}
}

View File

@@ -0,0 +1,145 @@
package org.signal.imageeditor.core.model;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.Stack;
/**
* Contains a stack of elements for undo and redo stacks.
* <p>
* Elements are mutable, so this stack serializes the element and keeps a stack of serialized data.
* <p>
* The stack has a {@link #limit} and if it exceeds that limit during a push the second to earliest item
* is removed so that it can always go back to the first state. Effectively collapsing the history for
* the start of the stack.
*/
final class ElementStack implements Parcelable {
private final int limit;
private final Stack<byte[]> stack = new Stack<>();
ElementStack(int limit) {
this.limit = limit;
}
private ElementStack(@NonNull Parcel in) {
this(in.readInt());
final int count = in.readInt();
for (int i = 0; i < count; i++) {
stack.add(i, in.createByteArray());
}
}
/**
* Pushes an element to the stack iff the element's serialized value is different to any found at
* the top of the stack.
* <p>
* Removes the second to earliest item if it is overflowing.
*
* @param element new editor element state.
* @return true iff the pushed item was different to the top item.
*/
boolean tryPush(@NonNull EditorElement element) {
byte[] bytes = getBytes(element);
boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek());
if (push) {
stack.push(bytes);
if (stack.size() > limit) {
stack.remove(1);
}
}
return push;
}
static byte[] getBytes(@NonNull Parcelable parcelable) {
Parcel parcel = Parcel.obtain();
byte[] bytes;
try {
parcel.writeParcelable(parcelable, 0);
bytes = parcel.marshall();
} finally {
parcel.recycle();
}
return bytes;
}
/**
* Pops the first different state from the supplied element.
*/
@Nullable EditorElement pop(@NonNull EditorElement element) {
if (stack.empty()) return null;
byte[] elementBytes = getBytes(element);
byte[] stackData = null;
while (!stack.empty() && stackData == null) {
byte[] topData = stack.pop();
if (!Arrays.equals(topData, elementBytes)) {
stackData = topData;
}
}
if (stackData == null) return null;
Parcel parcel = Parcel.obtain();
try {
parcel.unmarshall(stackData, 0, stackData.length);
parcel.setDataPosition(0);
return parcel.readParcelable(EditorElement.class.getClassLoader());
} finally {
parcel.recycle();
}
}
void clear() {
stack.clear();
}
public static final Creator<ElementStack> CREATOR = new Creator<ElementStack>() {
@Override
public ElementStack createFromParcel(Parcel in) {
return new ElementStack(in);
}
@Override
public ElementStack[] newArray(int size) {
return new ElementStack[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(limit);
final int count = stack.size();
dest.writeInt(count);
for (int i = 0; i < count; i++) {
dest.writeByteArray(stack.get(i));
}
}
boolean stackContainsStateDifferentFrom(@NonNull EditorElement element) {
if (stack.isEmpty()) return false;
byte[] currentStateBytes = getBytes(element);
for (byte[] item : stack) {
if (!Arrays.equals(item, currentStateBytes)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,35 @@
package org.signal.imageeditor.core.model;
import android.graphics.Matrix;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
final class InBoundsMemory {
private final Matrix lastGoodUserCrop = new Matrix();
private final Matrix lastGoodMainImage = new Matrix();
void push(@Nullable EditorElement mainImage, @NonNull EditorElement userCrop) {
if (mainImage == null) {
lastGoodMainImage.reset();
} else {
lastGoodMainImage.set(mainImage.getLocalMatrix());
lastGoodMainImage.preConcat(mainImage.getEditorMatrix());
}
lastGoodUserCrop.set(userCrop.getLocalMatrix());
lastGoodUserCrop.preConcat(userCrop.getEditorMatrix());
}
void restore(@Nullable EditorElement mainImage, @NonNull EditorElement cropEditorElement, @Nullable Runnable invalidate) {
if (mainImage != null) {
mainImage.animateLocalTo(lastGoodMainImage, invalidate);
}
cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate);
}
Matrix getLastKnownGoodMainImageMatrix() {
return new Matrix(lastGoodMainImage);
}
}

View File

@@ -0,0 +1,57 @@
package org.signal.imageeditor.core.model;
import android.graphics.Matrix;
import android.graphics.RectF;
import android.os.Parcel;
import androidx.annotation.NonNull;
import java.util.UUID;
public final class ParcelUtils {
private ParcelUtils() {
}
public static void writeMatrix(@NonNull Parcel dest, @NonNull Matrix matrix) {
float[] values = new float[9];
matrix.getValues(values);
dest.writeFloatArray(values);
}
public static void readMatrix(@NonNull Matrix matrix, @NonNull Parcel in) {
float[] values = new float[9];
in.readFloatArray(values);
matrix.setValues(values);
}
public static @NonNull Matrix readMatrix(@NonNull Parcel in) {
Matrix matrix = new Matrix();
readMatrix(matrix, in);
return matrix;
}
public static void writeRect(@NonNull Parcel dest, @NonNull RectF rect) {
dest.writeFloat(rect.left);
dest.writeFloat(rect.top);
dest.writeFloat(rect.right);
dest.writeFloat(rect.bottom);
}
public static @NonNull RectF readRectF(@NonNull Parcel in) {
float left = in.readFloat();
float top = in.readFloat();
float right = in.readFloat();
float bottom = in.readFloat();
return new RectF(left, top, right, bottom);
}
static UUID readUUID(@NonNull Parcel in) {
return new UUID(in.readLong(), in.readLong());
}
static void writeUUID(@NonNull Parcel dest, @NonNull UUID uuid) {
dest.writeLong(uuid.getMostSignificantBits());
dest.writeLong(uuid.getLeastSignificantBits());
}
}

View File

@@ -0,0 +1,77 @@
package org.signal.imageeditor.core.model;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
import java.util.UUID;
/**
* A special {@link Renderer} that controls another {@link EditorElement}.
* <p>
* It has a reference to the {@link EditorElement#getId()} and a {@link ControlPoint} which it is in control of.
* <p>
* The presence of this interface on the selected element is used to launch a ThumbDragEditSession.
*/
public interface ThumbRenderer extends Renderer {
enum ControlPoint {
CENTER_LEFT (Bounds.LEFT, Bounds.CENTRE_Y),
CENTER_RIGHT (Bounds.RIGHT, Bounds.CENTRE_Y),
TOP_CENTER (Bounds.CENTRE_X, Bounds.TOP),
BOTTOM_CENTER (Bounds.CENTRE_X, Bounds.BOTTOM),
TOP_LEFT (Bounds.LEFT, Bounds.TOP),
TOP_RIGHT (Bounds.RIGHT, Bounds.TOP),
BOTTOM_LEFT (Bounds.LEFT, Bounds.BOTTOM),
BOTTOM_RIGHT (Bounds.RIGHT, Bounds.BOTTOM);
private final float x;
private final float y;
ControlPoint(float x, float y) {
this.x = x;
this.y = y;
}
public float getX() {
return x;
}
public float getY() {
return y;
}
public ControlPoint opposite() {
switch (this) {
case CENTER_LEFT: return CENTER_RIGHT;
case CENTER_RIGHT: return CENTER_LEFT;
case TOP_CENTER: return BOTTOM_CENTER;
case BOTTOM_CENTER: return TOP_CENTER;
case TOP_LEFT: return BOTTOM_RIGHT;
case TOP_RIGHT: return BOTTOM_LEFT;
case BOTTOM_LEFT: return TOP_RIGHT;
case BOTTOM_RIGHT: return TOP_LEFT;
default:
throw new RuntimeException();
}
}
public boolean isHorizontalCenter() {
return this == ControlPoint.CENTER_LEFT || this == ControlPoint.CENTER_RIGHT;
}
public boolean isVerticalCenter() {
return this == ControlPoint.TOP_CENTER || this == ControlPoint.BOTTOM_CENTER;
}
public boolean isCenter() {
return isHorizontalCenter() || isVerticalCenter();
}
}
ControlPoint getControlPoint();
UUID getElementToControl();
}

View File

@@ -0,0 +1,94 @@
package org.signal.imageeditor.core.model;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
final class UndoRedoStacks implements Parcelable {
private final ElementStack undoStack;
private final ElementStack redoStack;
@NonNull
private byte[] unchangedState;
UndoRedoStacks(int limit) {
this(new ElementStack(limit), new ElementStack(limit), null);
}
private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack, @Nullable byte[] unchangedState) {
this.undoStack = undoStack;
this.redoStack = redoStack;
this.unchangedState = unchangedState != null ? unchangedState : new byte[0];
}
public static final Creator<UndoRedoStacks> CREATOR = new Creator<UndoRedoStacks>() {
@Override
public UndoRedoStacks createFromParcel(Parcel in) {
return new UndoRedoStacks(
in.readParcelable(ElementStack.class.getClassLoader()),
in.readParcelable(ElementStack.class.getClassLoader()),
in.createByteArray()
);
}
@Override
public UndoRedoStacks[] newArray(int size) {
return new UndoRedoStacks[size];
}
};
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(undoStack, flags);
dest.writeParcelable(redoStack, flags);
dest.writeByteArray(unchangedState);
}
@Override
public int describeContents() {
return 0;
}
ElementStack getUndoStack() {
return undoStack;
}
ElementStack getRedoStack() {
return redoStack;
}
void pushState(@NonNull EditorElement element) {
if (undoStack.tryPush(element)) {
redoStack.clear();
}
}
void clear(@NonNull EditorElement element) {
undoStack.clear();
redoStack.clear();
unchangedState = ElementStack.getBytes(element);
}
boolean isChanged(@NonNull EditorElement element) {
return !Arrays.equals(ElementStack.getBytes(element), unchangedState);
}
/**
* As long as there is something different in the stack somewhere, then we can undo.
*/
boolean canUndo(@NonNull EditorElement currentState) {
return undoStack.stackContainsStateDifferentFrom(currentState);
}
/**
* As long as there is something different in the stack somewhere, then we can redo.
*/
boolean canRedo(@NonNull EditorElement currentState) {
return redoStack.stackContainsStateDifferentFrom(currentState);
}
}

View File

@@ -0,0 +1,225 @@
package org.signal.imageeditor.core.renderers;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
/**
* Given points for a line to go though, automatically finds control points.
* <p>
* Based on http://www.particleincell.com/2012/bezier-splines/
* <p>
* Can then draw that line to a {@link Canvas} given a {@link Paint}.
* <p>
* Allocation efficient so that adding new points does not result in lots of array allocations.
*/
final class AutomaticControlPointBezierLine implements Parcelable {
private static final int INITIAL_CAPACITY = 256;
private float[] x;
private float[] y;
// control points
private float[] p1x;
private float[] p1y;
private float[] p2x;
private float[] p2y;
private int count;
private final Path path = new Path();
private AutomaticControlPointBezierLine(@Nullable float[] x, @Nullable float[] y, int count) {
this.count = count;
this.x = x != null ? x : new float[INITIAL_CAPACITY];
this.y = y != null ? y : new float[INITIAL_CAPACITY];
allocControlPointsAndWorkingMemory(this.x.length);
recalculateControlPoints();
}
AutomaticControlPointBezierLine() {
this(null, null, 0);
}
void reset() {
count = 0;
path.reset();
}
/**
* Adds a new point to the end of the line but ignores points that are too close to the last.
*
* @param x new x point
* @param y new y point
* @param thickness the maximum distance to allow, line thickness is recommended.
*/
void addPointFiltered(float x, float y, float thickness) {
if (count > 0) {
float dx = this.x[count - 1] - x;
float dy = this.y[count - 1] - y;
if (dx * dx + dy * dy < thickness * thickness) {
return;
}
}
addPoint(x, y);
}
/**
* Adds a new point to the end of the line.
*
* @param x new x point
* @param y new y point
*/
void addPoint(float x, float y) {
if (this.x == null || count == this.x.length) {
resize(this.x != null ? this.x.length << 1 : INITIAL_CAPACITY);
}
this.x[count] = x;
this.y[count] = y;
count++;
recalculateControlPoints();
}
private void resize(int newCapacity) {
x = Arrays.copyOf(x, newCapacity);
y = Arrays.copyOf(y, newCapacity);
allocControlPointsAndWorkingMemory(newCapacity - 1);
}
private void allocControlPointsAndWorkingMemory(int max) {
p1x = new float[max];
p1y = new float[max];
p2x = new float[max];
p2y = new float[max];
a = new float[max];
b = new float[max];
c = new float[max];
r = new float[max];
}
private void recalculateControlPoints() {
path.reset();
if (count > 2) {
computeControlPoints(x, p1x, p2x, count);
computeControlPoints(y, p1y, p2y, count);
}
path.moveTo(x[0], y[0]);
switch (count) {
case 1:
path.lineTo(x[0], y[0]);
break;
case 2:
path.lineTo(x[1], y[1]);
break;
default:
for (int i = 1; i < count - 1; i++) {
path.cubicTo(p1x[i], p1y[i], p2x[i], p2y[i], x[i + 1], y[i + 1]);
}
}
}
/**
* Draw the line.
*
* @param canvas The canvas to draw on.
* @param paint The paint to use.
*/
void draw(@NonNull Canvas canvas, @NonNull Paint paint) {
canvas.drawPath(path, paint);
}
// rhs vector for computeControlPoints method
private float[] a;
private float[] b;
private float[] c;
private float[] r;
/**
* Based on http://www.particleincell.com/2012/bezier-splines/
*
* @param k knots x or y, must be at least 2 entries
* @param p1 corresponding first control point x or y
* @param p2 corresponding second control point x or y
* @param count number of k to process
*/
private void computeControlPoints(float[] k, float[] p1, float[] p2, int count) {
final int n = count - 1;
// left most segment
a[0] = 0;
b[0] = 2;
c[0] = 1;
r[0] = k[0] + 2 * k[1];
// internal segments
for (int i = 1; i < n - 1; i++) {
a[i] = 1;
b[i] = 4;
c[i] = 1;
r[i] = 4 * k[i] + 2 * k[i + 1];
}
// right segment
a[n - 1] = 2;
b[n - 1] = 7;
c[n - 1] = 0;
r[n - 1] = 8 * k[n - 1] + k[n];
// solves Ax=b with the Thomas algorithm
for (int i = 1; i < n; i++) {
float m = a[i] / b[i - 1];
b[i] = b[i] - m * c[i - 1];
r[i] = r[i] - m * r[i - 1];
}
p1[n - 1] = r[n - 1] / b[n - 1];
for (int i = n - 2; i >= 0; --i) {
p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i];
}
// we have p1, now compute p2
for (int i = 0; i < n - 1; i++) {
p2[i] = 2 * k[i + 1] - p1[i + 1];
}
p2[n - 1] = 0.5f * (k[n] + p1[n - 1]);
}
public static final Creator<AutomaticControlPointBezierLine> CREATOR = new Creator<AutomaticControlPointBezierLine>() {
@Override
public AutomaticControlPointBezierLine createFromParcel(Parcel in) {
float[] x = in.createFloatArray();
float[] y = in.createFloatArray();
return new AutomaticControlPointBezierLine(x, y, x != null ? x.length : 0);
}
@Override
public AutomaticControlPointBezierLine[] newArray(int size) {
return new AutomaticControlPointBezierLine[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeFloatArray(Arrays.copyOfRange(x, 0, count));
dest.writeFloatArray(Arrays.copyOfRange(y, 0, count));
}
}

View File

@@ -0,0 +1,147 @@
package org.signal.imageeditor.core.renderers;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PointF;
import android.graphics.RectF;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.imageeditor.core.ColorableRenderer;
import org.signal.imageeditor.core.RendererContext;
/**
* Renders a {@link AutomaticControlPointBezierLine} with {@link #thickness}, {@link #color} and {@link #cap} end type.
*/
public final class BezierDrawingRenderer extends InvalidateableRenderer implements ColorableRenderer {
private final Paint paint;
private final AutomaticControlPointBezierLine bezierLine;
private final Paint.Cap cap;
@Nullable
private final RectF clipRect;
private int color;
private float thickness;
private BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable AutomaticControlPointBezierLine bezierLine, @Nullable RectF clipRect) {
this.paint = new Paint();
this.color = color;
this.thickness = thickness;
this.cap = cap;
this.clipRect = clipRect;
this.bezierLine = bezierLine != null ? bezierLine : new AutomaticControlPointBezierLine();
updatePaint();
}
public BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable RectF clipRect) {
this(color, thickness, cap,null, clipRect != null ? new RectF(clipRect) : null);
}
@Override
public int getColor() {
return color;
}
@Override
public void setColor(int color) {
if (this.color != color) {
this.color = color;
updatePaint();
invalidate();
}
}
public void setThickness(float thickness) {
if (this.thickness != thickness) {
this.thickness = thickness;
updatePaint();
invalidate();
}
}
private void updatePaint() {
paint.setColor(color);
paint.setStrokeWidth(thickness);
paint.setStyle(Paint.Style.STROKE);
paint.setAntiAlias(true);
paint.setStrokeCap(cap);
}
public void setFirstPoint(PointF point) {
bezierLine.reset();
bezierLine.addPoint(point.x, point.y);
invalidate();
}
public void addNewPoint(PointF point) {
if (cap != Paint.Cap.ROUND) {
bezierLine.addPointFiltered(point.x, point.y, thickness * 0.5f);
} else {
bezierLine.addPoint(point.x, point.y);
}
invalidate();
}
@Override
public void render(@NonNull RendererContext rendererContext) {
super.render(rendererContext);
Canvas canvas = rendererContext.canvas;
canvas.save();
if (clipRect != null) {
canvas.clipRect(clipRect);
}
int alpha = paint.getAlpha();
paint.setAlpha(rendererContext.getAlpha(alpha));
paint.setXfermode(rendererContext.getMaskPaint() != null ? rendererContext.getMaskPaint().getXfermode() : null);
bezierLine.draw(canvas, paint);
paint.setAlpha(alpha);
rendererContext.canvas.restore();
}
@Override
public boolean hitTest(float x, float y) {
return false;
}
public static final Creator<BezierDrawingRenderer> CREATOR = new Creator<BezierDrawingRenderer>() {
@Override
public BezierDrawingRenderer createFromParcel(Parcel in) {
int color = in.readInt();
float thickness = in.readFloat();
Paint.Cap cap = Paint.Cap.values()[in.readInt()];
AutomaticControlPointBezierLine bezierLine = in.readParcelable(AutomaticControlPointBezierLine.class.getClassLoader());
RectF clipRect = in.readParcelable(RectF.class.getClassLoader());
return new BezierDrawingRenderer(color, thickness, cap, bezierLine, clipRect);
}
@Override
public BezierDrawingRenderer[] newArray(int size) {
return new BezierDrawingRenderer[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(color);
dest.writeFloat(thickness);
dest.writeInt(cap.ordinal());
dest.writeParcelable(bezierLine, flags);
dest.writeParcelable(clipRect, flags);
}
}

View File

@@ -0,0 +1,135 @@
package org.signal.imageeditor.core.renderers;
import android.content.res.Resources;
import android.graphics.Canvas;
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;
import org.signal.imageeditor.R;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
/**
* Renders a box outside of the current crop area using {@link R.color#crop_area_renderer_outer_color}
* and around the edge it renders the markers for the thumbs using {@link R.color#crop_area_renderer_edge_color},
* {@link R.dimen#crop_area_renderer_edge_thickness} and {@link R.dimen#crop_area_renderer_edge_size}.
* <p>
* Hit tests outside of the bounds.
*/
public final class CropAreaRenderer implements Renderer {
@ColorRes
private final int color;
private final boolean renderCenterThumbs;
private final Path cropClipPath = new Path();
private final Path screenClipPath = new Path();
private final RectF dst = new RectF();
private final Paint paint = new Paint();
@Override
public void render(@NonNull RendererContext rendererContext) {
rendererContext.save();
Canvas canvas = rendererContext.canvas;
Resources resources = rendererContext.context.getResources();
canvas.clipPath(cropClipPath);
canvas.drawColor(ResourcesCompat.getColor(resources, color, null));
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
final int thickness = resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_thickness);
final int size = (int) Math.min(resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size), Math.min(dst.width(), dst.height()) / 3f - 10);
paint.setColor(ResourcesCompat.getColor(resources, R.color.crop_area_renderer_edge_color, null));
rendererContext.canvasMatrix.setToIdentity();
screenClipPath.reset();
screenClipPath.moveTo(dst.left, dst.top);
screenClipPath.lineTo(dst.right, dst.top);
screenClipPath.lineTo(dst.right, dst.bottom);
screenClipPath.lineTo(dst.left, dst.bottom);
screenClipPath.close();
canvas.clipPath(screenClipPath);
canvas.translate(dst.left, dst.top);
float halfDx = (dst.right - dst.left - size + thickness) / 2;
float halfDy = (dst.bottom - dst.top - size + thickness) / 2;
canvas.drawRect(-thickness, -thickness, size, size, paint);
canvas.translate(0, halfDy);
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
canvas.translate(0, halfDy);
canvas.drawRect(-thickness, -thickness, size, size, paint);
canvas.translate(halfDx, 0);
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
canvas.translate(halfDx, 0);
canvas.drawRect(-thickness, -thickness, size, size, paint);
canvas.translate(0, -halfDy);
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
canvas.translate(0, -halfDy);
canvas.drawRect(-thickness, -thickness, size, size, paint);
canvas.translate(-halfDx, 0);
if (renderCenterThumbs) canvas.drawRect(-thickness, -thickness, size, size, paint);
rendererContext.restore();
}
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);
cropClipPath.lineTo(Bounds.RIGHT, Bounds.BOTTOM);
cropClipPath.lineTo(Bounds.LEFT, Bounds.BOTTOM);
cropClipPath.close();
screenClipPath.toggleInverseFillType();
}
@Override
public boolean hitTest(float x, float y) {
return !Bounds.contains(x, y);
}
public static final Creator<CropAreaRenderer> CREATOR = new Creator<CropAreaRenderer>() {
@Override
public @NonNull CropAreaRenderer createFromParcel(@NonNull Parcel in) {
return new CropAreaRenderer(in.readInt(),
in.readByte() == 1);
}
@Override
public @NonNull CropAreaRenderer[] newArray(int size) {
return new CropAreaRenderer[size];
}
};
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(color);
dest.writeByte((byte) (renderCenterThumbs ? 1 : 0));
}
@Override
public int describeContents() {
return 0;
}
}

View File

@@ -0,0 +1,46 @@
package org.signal.imageeditor.core.renderers;
import android.os.Parcel;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
/**
* A rectangle that will be rendered on the blur mask layer. Intended for blurring faces.
*/
public final class FaceBlurRenderer implements Renderer {
@Override
public void render(@NonNull RendererContext rendererContext) {
rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, rendererContext.getMaskPaint());
}
@Override
public boolean hitTest(float x, float y) {
return Bounds.FULL_BOUNDS.contains(x, y);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
}
public static final Creator<FaceBlurRenderer> CREATOR = new Creator<FaceBlurRenderer>() {
@Override
public FaceBlurRenderer createFromParcel(Parcel in) {
return new FaceBlurRenderer();
}
@Override
public FaceBlurRenderer[] newArray(int size) {
return new FaceBlurRenderer[size];
}
};
}

View File

@@ -0,0 +1,76 @@
package org.signal.imageeditor.core.renderers;
import android.graphics.Path;
import android.graphics.RectF;
import android.os.Parcel;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import org.signal.core.util.DimensionUnit;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
/**
* Renders the {@link color} outside of the {@link Bounds}.
* <p>
* Hit tests outside of the bounds.
*/
public final class FillRenderer implements Renderer {
private final int color;
private final RectF dst = new RectF();
private final Path path = new Path();
@Override
public void render(@NonNull RendererContext rendererContext) {
rendererContext.canvas.save();
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
rendererContext.canvasMatrix.setToIdentity();
path.reset();
path.addRoundRect(dst, DimensionUnit.DP.toPixels(18), DimensionUnit.DP.toPixels(18), Path.Direction.CW);
rendererContext.canvas.clipPath(path);
rendererContext.canvas.drawColor(color);
rendererContext.canvas.restore();
}
public FillRenderer(@ColorInt int color) {
this.color = color;
}
private FillRenderer(Parcel in) {
this(in.readInt());
}
@Override
public boolean hitTest(float x, float y) {
return !Bounds.contains(x, y);
}
public static final Creator<FillRenderer> CREATOR = new Creator<FillRenderer>() {
@Override
public FillRenderer createFromParcel(Parcel in) {
return new FillRenderer(in);
}
@Override
public FillRenderer[] newArray(int size) {
return new FillRenderer[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(color);
}
}

View File

@@ -0,0 +1,34 @@
package org.signal.imageeditor.core.renderers;
import androidx.annotation.NonNull;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
import java.lang.ref.WeakReference;
/**
* Maintains a weak reference to the an invalidate callback allowing future invalidation without memory leak risk.
*/
abstract class InvalidateableRenderer implements Renderer {
private WeakReference<RendererContext.Invalidate> invalidate = new WeakReference<>(null);
@Override
public void render(@NonNull RendererContext rendererContext) {
setInvalidate(rendererContext.invalidate);
}
private void setInvalidate(RendererContext.Invalidate invalidate) {
if (invalidate != this.invalidate.get()) {
this.invalidate = new WeakReference<>(invalidate);
}
}
protected void invalidate() {
RendererContext.Invalidate invalidate = this.invalidate.get();
if (invalidate != null) {
invalidate.onInvalidate(this);
}
}
}

View File

@@ -0,0 +1,77 @@
package org.signal.imageeditor.core.renderers;
import android.graphics.Path;
import android.graphics.RectF;
import android.os.Parcel;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import org.signal.core.util.DimensionUnit;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.RendererContext;
/**
* Renders the {@link color} outside of the {@link Bounds}.
* <p>
* Hit tests outside of the bounds.
*/
public final class InverseFillRenderer implements Renderer {
private final int color;
private final RectF dst = new RectF();
private final Path path = new Path();
@Override
public void render(@NonNull RendererContext rendererContext) {
rendererContext.canvas.save();
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
rendererContext.canvasMatrix.setToIdentity();
path.reset();
path.addRoundRect(dst, DimensionUnit.DP.toPixels(18), DimensionUnit.DP.toPixels(18), Path.Direction.CW);
rendererContext.canvas.clipPath(path);
rendererContext.canvas.drawColor(color);
rendererContext.canvas.restore();
}
public InverseFillRenderer(@ColorInt int color) {
this.color = color;
path.toggleInverseFillType();
}
private InverseFillRenderer(Parcel in) {
this(in.readInt());
}
@Override
public boolean hitTest(float x, float y) {
return !Bounds.contains(x, y);
}
public static final Creator<InverseFillRenderer> CREATOR = new Creator<InverseFillRenderer>() {
@Override
public InverseFillRenderer createFromParcel(Parcel in) {
return new InverseFillRenderer(in);
}
@Override
public InverseFillRenderer[] newArray(int size) {
return new InverseFillRenderer[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(color);
}
}

View File

@@ -0,0 +1,549 @@
package org.signal.imageeditor.core.renderers;
import android.animation.ValueAnimator;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.os.Build;
import android.os.Parcel;
import android.view.animation.Interpolator;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.DimensionUnit;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.ColorableRenderer;
import org.signal.imageeditor.core.RendererContext;
import org.signal.imageeditor.core.SelectableRenderer;
import java.util.ArrayList;
import java.util.List;
import static java.util.Collections.emptyList;
/**
* Renders multiple lines of {@link #text} in ths specified {@link #color}.
* <p>
* Scales down the text size of long lines to fit inside the {@link Bounds} width.
*/
public final class MultiLineTextRenderer extends InvalidateableRenderer implements ColorableRenderer, SelectableRenderer {
private static final float HIT_PADDING = DimensionUnit.DP.toPixels(30);
private static final float HIGHLIGHT_HORIZONTAL_PADDING = DimensionUnit.DP.toPixels(8);
private static final float HIGHLIGHT_TOP_PADDING = DimensionUnit.DP.toPixels(10);
private static final float HIGHLIGHT_BOTTOM_PADDING = DimensionUnit.DP.toPixels(6);
private static final float HIGHLIGHT_CORNER_RADIUS = DimensionUnit.DP.toPixels(4);
@NonNull
private String text = "";
@ColorInt
private int color;
private final Paint paint = new Paint();
private final Paint selectionPaint = new Paint();
private final Paint modePaint = new Paint();
private final float textScale;
private int selStart;
private int selEnd;
private boolean hasFocus;
private boolean selected;
private Mode mode;
private List<Line> lines = emptyList();
private ValueAnimator cursorAnimator;
private float cursorAnimatedValue;
private final Matrix recommendedEditorMatrix = new Matrix();
private final SelectedElementGuideRenderer selectedElementGuideRenderer = new SelectedElementGuideRenderer();
private final RectF textBounds = new RectF();
public MultiLineTextRenderer(@Nullable String text, @ColorInt int color, @NonNull Mode mode) {
this.mode = mode;
Typeface typeface = getTypeface();
modePaint.setAntiAlias(true);
modePaint.setTextSize(100);
modePaint.setTypeface(typeface);
setColorInternal(color);
float regularTextSize = paint.getTextSize();
paint.setAntiAlias(true);
paint.setTextSize(100);
paint.setTypeface(typeface);
textScale = paint.getTextSize() / regularTextSize;
selectionPaint.setAntiAlias(true);
setText(text != null ? text : "");
createLinesForText();
}
@Override
public void render(@NonNull RendererContext rendererContext) {
super.render(rendererContext);
float height = 0;
float width = 0;
for (Line line : lines) {
line.render(rendererContext);
height += line.heightInBounds - line.ascentInBounds + line.descentInBounds;
width = Math.max(line.textBounds.width(), width);
}
if (selected && rendererContext.isEditing()) {
textBounds.set(-width, -height / 2f, width, 0f);
selectedElementGuideRenderer.render(rendererContext, textBounds);
}
}
@NonNull
public String getText() {
return text;
}
public void setText(@NonNull String text) {
if (!this.text.equals(text)) {
this.text = text;
createLinesForText();
}
}
public void nextMode() {
setMode(Mode.fromCode(mode.code + 1));
}
public @NonNull Mode getMode() {
return mode;
}
/**
* Post concats an additional matrix to the supplied matrix that scales and positions the editor
* so that all the text is visible.
*
* @param matrix editor matrix, already zoomed and positioned to fit the regular bounds.
*/
public void applyRecommendedEditorMatrix(@NonNull Matrix matrix) {
recommendedEditorMatrix.reset();
float scale = 1f;
for (Line line : lines) {
if (line.scale < scale) {
scale = line.scale;
}
}
float yOff = 0;
for (Line line : lines) {
if (line.containsSelectionEnd()) {
break;
} else {
yOff -= line.heightInBounds;
}
}
recommendedEditorMatrix.postTranslate(0, Bounds.TOP / 1.5f + yOff);
recommendedEditorMatrix.postScale(scale, scale);
matrix.postConcat(recommendedEditorMatrix);
}
private void createLinesForText() {
String[] split = text.split("\n", -1);
if (split.length == lines.size()) {
for (int i = 0; i < split.length; i++) {
lines.get(i).setText(split[i]);
}
} else {
lines = new ArrayList<>(split.length);
for (String s : split) {
lines.add(new Line(s));
}
}
setSelection(selStart, selEnd);
}
private class Line {
private final Matrix accentMatrix = new Matrix();
private final Matrix decentMatrix = new Matrix();
private final Matrix projectionMatrix = new Matrix();
private final Matrix inverseProjectionMatrix = new Matrix();
private final RectF selectionBounds = new RectF();
private final RectF textBounds = new RectF();
private final RectF hitBounds = new RectF();
private final RectF modeBounds = new RectF();
private String text;
private int selStart;
private int selEnd;
private float ascentInBounds;
private float descentInBounds;
private float scale = 1f;
private float heightInBounds;
Line(String text) {
this.text = text;
recalculate();
}
private void recalculate() {
RectF maxTextBounds = new RectF();
Rect temp = new Rect();
getTextBoundsWithoutTrim(text, 0, text.length(), temp);
textBounds.set(temp);
hitBounds.set(textBounds);
hitBounds.left -= HIT_PADDING;
hitBounds.right += HIT_PADDING;
hitBounds.top -= HIT_PADDING;
hitBounds.bottom += HIT_PADDING;
maxTextBounds.set(textBounds);
float widthLimit = 150 * textScale;
scale = 1f / Math.max(1, maxTextBounds.right / widthLimit);
maxTextBounds.right = widthLimit;
if (showSelectionOrCursor()) {
Rect startTemp = new Rect();
int startInString = Math.min(text.length(), Math.max(0, selStart));
int endInString = Math.min(text.length(), Math.max(0, selEnd));
String startText = this.text.substring(0, startInString);
getTextBoundsWithoutTrim(startText, 0, startInString, startTemp);
if (selStart != selEnd) {
// selection
getTextBoundsWithoutTrim(text, startInString, endInString, temp);
} else {
// cursor
paint.getTextBounds("|", 0, 1, temp);
int width = temp.width();
temp.left -= width;
temp.right -= width;
}
temp.left += startTemp.right;
temp.right += startTemp.right;
selectionBounds.set(temp);
}
projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER);
removeTranslate(projectionMatrix);
float[] pts = { 0, paint.ascent(), 0, paint.descent() };
projectionMatrix.mapPoints(pts);
ascentInBounds = pts[1];
descentInBounds = pts[3];
heightInBounds = descentInBounds - ascentInBounds;
projectionMatrix.preTranslate(-textBounds.centerX(), 0);
projectionMatrix.invert(inverseProjectionMatrix);
accentMatrix.setTranslate(0, -ascentInBounds);
decentMatrix.setTranslate(0, descentInBounds);
invalidate();
}
private void removeTranslate(Matrix matrix) {
float[] values = new float[9];
matrix.getValues(values);
values[2] = 0;
values[5] = 0;
matrix.setValues(values);
}
private boolean showSelectionOrCursor() {
return (selStart >= 0 || selEnd >= 0) &&
(selStart <= text.length() || selEnd <= text.length());
}
private boolean containsSelectionEnd() {
return (selEnd >= 0) &&
(selEnd <= text.length());
}
private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) {
Rect extra = new Rect();
Rect xBounds = new Rect();
String cannotBeTrimmed = "x" + text.substring(Math.max(0, start), Math.min(text.length(), end)) + "x";
paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra);
paint.getTextBounds("x", 0, 1, xBounds);
result.set(extra);
result.right -= 2 * xBounds.width();
int temp = result.left;
result.left -= temp;
result.right -= temp;
}
public boolean contains(float x, float y) {
float[] dst = new float[2];
inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y });
return hitBounds.contains(dst[0], dst[1]);
}
void setText(String text) {
if (!this.text.equals(text)) {
this.text = text;
recalculate();
}
}
public void render(@NonNull RendererContext rendererContext) {
// add our ascent for ourselves and the next lines
rendererContext.canvasMatrix.concat(accentMatrix);
rendererContext.save();
rendererContext.canvasMatrix.concat(projectionMatrix);
if (mode == Mode.HIGHLIGHT) {
modeBounds.set(textBounds.left - HIGHLIGHT_HORIZONTAL_PADDING,
selectionBounds.top - HIGHLIGHT_TOP_PADDING,
textBounds.right + HIGHLIGHT_HORIZONTAL_PADDING,
selectionBounds.bottom + HIGHLIGHT_BOTTOM_PADDING);
int alpha = modePaint.getAlpha();
modePaint.setAlpha(rendererContext.getAlpha(alpha));
rendererContext.canvas.drawRoundRect(modeBounds, HIGHLIGHT_CORNER_RADIUS, HIGHLIGHT_CORNER_RADIUS, modePaint);
modePaint.setAlpha(alpha);
} else if (mode == Mode.UNDERLINE) {
modeBounds.set(textBounds.left, selectionBounds.top, textBounds.right, selectionBounds.bottom);
modeBounds.inset(-DimensionUnit.DP.toPixels(2), -DimensionUnit.DP.toPixels(2));
modeBounds.set(modeBounds.left,
Math.max(modeBounds.top, modeBounds.bottom - DimensionUnit.DP.toPixels(6)),
modeBounds.right,
modeBounds.bottom - DimensionUnit.DP.toPixels(2));
int alpha = modePaint.getAlpha();
modePaint.setAlpha(rendererContext.getAlpha(alpha));
rendererContext.canvas.drawRect(modeBounds, modePaint);
modePaint.setAlpha(alpha);
}
if (hasFocus && showSelectionOrCursor()) {
if (selStart == selEnd) {
selectionPaint.setAlpha((int) (cursorAnimatedValue * 128));
} else {
selectionPaint.setAlpha(128);
}
rendererContext.canvas.drawRect(selectionBounds, selectionPaint);
}
int alpha = paint.getAlpha();
paint.setAlpha(rendererContext.getAlpha(alpha));
rendererContext.canvas.drawText(text, 0, 0, paint);
paint.setAlpha(alpha);
if (mode == Mode.OUTLINE) {
int modeAlpha = modePaint.getAlpha();
modePaint.setAlpha(rendererContext.getAlpha(alpha));
rendererContext.canvas.drawText(text, 0, 0, modePaint);
modePaint.setAlpha(modeAlpha);
}
rendererContext.restore();
// add our descent for the next lines
rendererContext.canvasMatrix.concat(decentMatrix);
}
void setSelection(int selStart, int selEnd) {
if (selStart != this.selStart || selEnd != this.selEnd) {
this.selStart = selStart;
this.selEnd = selEnd;
recalculate();
}
}
}
@Override
public int getColor() {
return color;
}
@Override
public void setColor(@ColorInt int color) {
if (this.color != color) {
setColorInternal(color);
}
}
@Override
public void onSelected(boolean selected) {
if (this.selected != selected) {
this.selected = selected;
}
}
@Override
public boolean hitTest(float x, float y) {
for (Line line : lines) {
y += line.ascentInBounds;
if (line.contains(x, y)) return true;
y -= line.descentInBounds;
}
return false;
}
public void setSelection(int selStart, int selEnd) {
this.selStart = selStart;
this.selEnd = selEnd;
for (Line line : lines) {
line.setSelection(selStart, selEnd);
int length = line.text.length() + 1; // one for new line
selStart -= length;
selEnd -= length;
}
}
public void setFocused(boolean hasFocus) {
if (this.hasFocus != hasFocus) {
this.hasFocus = hasFocus;
if (cursorAnimator != null) {
cursorAnimator.cancel();
cursorAnimator = null;
}
if (hasFocus) {
cursorAnimator = ValueAnimator.ofFloat(0, 1);
cursorAnimator.setInterpolator(pulseInterpolator());
cursorAnimator.setRepeatCount(ValueAnimator.INFINITE);
cursorAnimator.setDuration(1000);
cursorAnimator.addUpdateListener(animation -> {
cursorAnimatedValue = (float) animation.getAnimatedValue();
invalidate();
});
cursorAnimator.start();
} else {
invalidate();
}
}
}
private void setMode(@NonNull Mode mode) {
if (this.mode != mode) {
this.mode = mode;
setColorInternal(color);
}
}
private void setColorInternal(@ColorInt int color) {
this.color = color;
if (mode == Mode.REGULAR) {
paint.setColor(color);
selectionPaint.setColor(color);
} else {
paint.setColor(Color.WHITE);
selectionPaint.setColor(Color.WHITE);
}
if (mode == Mode.OUTLINE) {
modePaint.setStrokeWidth(DimensionUnit.DP.toPixels(15) / 10f);
modePaint.setStyle(Paint.Style.STROKE);
} else {
modePaint.setStyle(Paint.Style.FILL);
}
modePaint.setColor(color);
invalidate();
}
public static final Creator<MultiLineTextRenderer> CREATOR = new Creator<MultiLineTextRenderer>() {
@Override
public MultiLineTextRenderer createFromParcel(Parcel in) {
return new MultiLineTextRenderer(in.readString(), in.readInt(), Mode.fromCode(in.readInt()));
}
@Override
public MultiLineTextRenderer[] newArray(int size) {
return new MultiLineTextRenderer[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(text);
dest.writeInt(color);
dest.writeInt(mode.code);
}
private static Interpolator pulseInterpolator() {
return input -> {
input *= 5;
if (input > 1) {
input = 4 - input;
}
return Math.max(0, Math.min(1, input));
};
}
private static @NonNull Typeface getTypeface() {
if (Build.VERSION.SDK_INT < 26) {
return Typeface.create(Typeface.DEFAULT, Typeface.BOLD);
} else {
return new Typeface.Builder("")
.setFallback("sans-serif")
.setWeight(900)
.build();
}
}
public enum Mode {
REGULAR(0),
HIGHLIGHT(1),
UNDERLINE(2),
OUTLINE(3);
private final int code;
Mode(int code) {
this.code = code;
}
private static Mode fromCode(int code) {
for (final Mode value : Mode.values()) {
if (value.code == code) {
return value;
}
}
return REGULAR;
}
}
}

View File

@@ -0,0 +1,86 @@
package org.signal.imageeditor.core.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.signal.imageeditor.R;
import org.signal.imageeditor.core.Bounds;
import org.signal.imageeditor.core.Renderer;
import org.signal.imageeditor.core.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,101 @@
package org.signal.imageeditor.core.renderers
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import org.signal.core.util.DimensionUnit
import org.signal.imageeditor.core.Bounds
import org.signal.imageeditor.core.RendererContext
class SelectedElementGuideRenderer {
companion object {
private const val PADDING: Int = 10
}
private val allPointsOnScreen = FloatArray(8)
private val allPointsInLocalCords = floatArrayOf(
Bounds.LEFT, Bounds.TOP,
Bounds.RIGHT, Bounds.TOP,
Bounds.RIGHT, Bounds.BOTTOM,
Bounds.LEFT, Bounds.BOTTOM
)
private val circleRadius = DimensionUnit.DP.toPixels(5f)
private val guidePaint = Paint().apply {
isAntiAlias = true
strokeWidth = DimensionUnit.DP.toPixels(1.5f)
color = Color.WHITE
style = Paint.Style.STROKE
pathEffect = DashPathEffect(floatArrayOf(15f, 15f), 0f)
}
private val circlePaint = Paint().apply {
isAntiAlias = true
color = Color.WHITE
style = Paint.Style.FILL
}
private val path = Path()
/**
* Draw self to the context.
*
* @param rendererContext The context to draw to.
*/
fun render(rendererContext: RendererContext) {
rendererContext.canvasMatrix.mapPoints(allPointsOnScreen, allPointsInLocalCords)
performRender(rendererContext)
}
fun render(rendererContext: RendererContext, contentBounds: RectF) {
rendererContext.canvasMatrix.mapPoints(
allPointsOnScreen,
floatArrayOf(
contentBounds.left - PADDING,
contentBounds.top - PADDING,
contentBounds.right + PADDING,
contentBounds.top - PADDING,
contentBounds.right + PADDING,
contentBounds.bottom + PADDING,
contentBounds.left - PADDING,
contentBounds.bottom + PADDING
)
)
performRender(rendererContext)
}
private fun performRender(rendererContext: RendererContext) {
rendererContext.save()
rendererContext.canvasMatrix.setToIdentity()
path.reset()
path.moveTo(allPointsOnScreen[0], allPointsOnScreen[1])
path.lineTo(allPointsOnScreen[2], allPointsOnScreen[3])
path.lineTo(allPointsOnScreen[4], allPointsOnScreen[5])
path.lineTo(allPointsOnScreen[6], allPointsOnScreen[7])
path.close()
rendererContext.canvas.drawPath(path, guidePaint)
// TODO: Implement scaling
// rendererContext.canvas.drawCircle(
// (allPointsOnScreen[6] + allPointsOnScreen[0]) / 2f,
// (allPointsOnScreen[7] + allPointsOnScreen[1]) / 2f,
// circleRadius,
// circlePaint
// )
// rendererContext.canvas.drawCircle(
// (allPointsOnScreen[4] + allPointsOnScreen[2]) / 2f,
// (allPointsOnScreen[5] + allPointsOnScreen[3]) / 2f,
// circleRadius,
// circlePaint
// )
rendererContext.restore()
}
}

View File

@@ -0,0 +1,151 @@
package org.signal.imageeditor.core.renderers
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.os.Parcel
import android.os.Parcelable
import android.view.animation.Interpolator
import androidx.appcompat.content.res.AppCompatResources
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.signal.core.util.DimensionUnit
import org.signal.imageeditor.R
import org.signal.imageeditor.core.Bounds
import org.signal.imageeditor.core.Renderer
import org.signal.imageeditor.core.RendererContext
internal class TrashRenderer : InvalidateableRenderer, Renderer, Parcelable {
private val outlinePaint = Paint().apply {
isAntiAlias = true
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = DimensionUnit.DP.toPixels(1.5f)
}
private val shadePaint = Paint().apply {
isAntiAlias = true
color = 0x99000000.toInt()
style = Paint.Style.FILL
}
private val bounds = RectF()
private val diameterSmall = DimensionUnit.DP.toPixels(41f)
private val diameterLarge = DimensionUnit.DP.toPixels(54f)
private val trashSize: Int = DimensionUnit.DP.toPixels(24f).toInt()
private val padBottom = DimensionUnit.DP.toPixels(16f)
private val interpolator: Interpolator = FastOutSlowInInterpolator()
private var startTime = 0L
private var isExpanding = false
private val buttonCenter = FloatArray(2)
constructor()
override fun render(rendererContext: RendererContext) {
super.render(rendererContext)
val frameRenderTime = System.currentTimeMillis()
val trash: Drawable = requireNotNull(AppCompatResources.getDrawable(rendererContext.context, R.drawable.ic_trash_white_24))
trash.setBounds(0, 0, trashSize, trashSize)
val diameter = getInterpolatedDiameter(frameRenderTime - startTime)
rendererContext.canvas.save()
rendererContext.mapRect(bounds, Bounds.FULL_BOUNDS)
buttonCenter[0] = bounds.centerX()
buttonCenter[1] = bounds.bottom - diameterLarge / 2f - padBottom
rendererContext.canvasMatrix.setToIdentity()
rendererContext.canvas.drawCircle(buttonCenter[0], buttonCenter[1], diameter / 2f, shadePaint)
rendererContext.canvas.drawCircle(buttonCenter[0], buttonCenter[1], diameter / 2f, outlinePaint)
rendererContext.canvas.translate(bounds.centerX(), bounds.bottom - diameterLarge / 2f - padBottom)
rendererContext.canvas.translate(- (trashSize / 2f), - (trashSize / 2f))
trash.draw(rendererContext.canvas)
rendererContext.canvas.restore()
if (frameRenderTime - DURATION < startTime) {
invalidate()
}
}
private fun getInterpolatedDiameter(timeElapsed: Long): Float {
return if (timeElapsed >= DURATION) {
if (isExpanding) {
diameterLarge
} else {
diameterSmall
}
} else {
val interpolatedFraction = interpolator.getInterpolation(timeElapsed / DURATION.toFloat())
if (isExpanding) {
interpolateFromFraction(interpolatedFraction)
} else {
interpolateFromFraction(1 - interpolatedFraction)
}
}
}
private fun interpolateFromFraction(fraction: Float): Float {
return diameterSmall + (diameterLarge - diameterSmall) * fraction
}
fun expand() {
if (isExpanding) {
return
}
isExpanding = true
startTime = System.currentTimeMillis()
invalidate()
}
fun shrink() {
if (!isExpanding) {
return
}
isExpanding = false
startTime = System.currentTimeMillis()
invalidate()
}
private constructor(inParcel: Parcel?)
override fun hitTest(x: Float, y: Float): Boolean {
val dx = x - buttonCenter[0]
val dy = y - buttonCenter[1]
val radius = diameterLarge / 2
return dx * dx + dy * dy < radius * radius
}
override fun describeContents(): Int {
return 0
}
override fun writeToParcel(dest: Parcel, flags: Int) {}
companion object {
private const val DURATION = 150L
@JvmField
val CREATOR: Parcelable.Creator<TrashRenderer> = object : Parcelable.Creator<TrashRenderer> {
override fun createFromParcel(`in`: Parcel): TrashRenderer {
return TrashRenderer(`in`)
}
override fun newArray(size: Int): Array<TrashRenderer?> {
return arrayOfNulls(size)
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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

@@ -0,0 +1,93 @@
// Auto-generated, use ./gradlew calculateChecksums to regenerate
dependencyVerification {
verify = [
['androidx.activity:activity:1.0.0',
'd1bc9842455c2e534415d88c44df4d52413b478db9093a1ba36324f705f44c3d'],
['androidx.annotation:annotation:1.2.0',
'9029262bddce116e6d02be499e4afdba21f24c239087b76b3b57d7e98b490a36'],
['androidx.appcompat:appcompat-resources:1.2.0',
'c470297c03ff3de1c3d15dacf0be0cae63abc10b52f021dd07ae28daa3100fe5'],
['androidx.appcompat:appcompat:1.2.0',
'3d2131a55a61a777322e2126e0018011efa6339e53b44153eb651b16020cca70'],
['androidx.arch.core:core-common:2.1.0',
'fe1237bf029d063e7f29fe39aeaf73ef74c8b0a3658486fc29d3c54326653889'],
['androidx.arch.core:core-runtime:2.0.0',
'87e65fc767c712b437649c7cee2431ebb4bed6daef82e501d4125b3ed3f65f8e'],
['androidx.collection:collection:1.1.0',
'632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72'],
['androidx.core:core-ktx:1.5.0',
'5964cfe7a4882da2a00fb6ca3d3a072d04139208186f7bc4b3cb66022764fc42'],
['androidx.core:core:1.5.0',
'2b279712795689069cfb63e48b3ab63c32a5649bdda44c482eb8f81ca1a72161'],
['androidx.cursoradapter:cursoradapter:1.0.0',
'a81c8fe78815fa47df5b749deb52727ad11f9397da58b16017f4eb2c11e28564'],
['androidx.customview:customview:1.0.0',
'20e5b8f6526a34595a604f56718da81167c0b40a7a94a57daa355663f2594df2'],
['androidx.drawerlayout:drawerlayout:1.0.0',
'9402442cdc5a43cf62fb14f8cf98c63342d4d9d9b805c8033c6cf7e802749ac1'],
['androidx.fragment:fragment:1.1.0',
'a14c8b8f2153f128e800fbd266a6beab1c283982a29ec570d2cc05d307d81496'],
['androidx.interpolator:interpolator:1.0.0',
'33193135a64fe21fa2c35eec6688f1a76e512606c0fc83dc1b689e37add7732a'],
['androidx.lifecycle:lifecycle-common:2.1.0',
'76db6be533bd730fb361c2feb12a2c26d9952824746847da82601ef81f082643'],
['androidx.lifecycle:lifecycle-livedata-core:2.0.0',
'fde334ec7e22744c0f5bfe7caf1a84c9d717327044400577bdf9bd921ec4f7bc'],
['androidx.lifecycle:lifecycle-livedata:2.0.0',
'c82609ced8c498f0a701a30fb6771bb7480860daee84d82e0a81ee86edf7ba39'],
['androidx.lifecycle:lifecycle-runtime:2.1.0',
'e5173897b965e870651e83d9d5af1742d3f532d58863223a390ce3a194c8312b'],
['androidx.lifecycle:lifecycle-viewmodel:2.1.0',
'ba55fb7ac1b2828d5327cda8acf7085d990b2b4c43ef336caa67686249b8523d'],
['androidx.loader:loader:1.0.0',
'11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025'],
['androidx.savedstate:savedstate:1.0.0',
'2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83'],
['androidx.vectordrawable:vectordrawable-animated:1.1.0',
'76da2c502371d9c38054df5e2b248d00da87809ed058f3363eae87ce5e2403f8'],
['androidx.vectordrawable:vectordrawable:1.1.0',
'46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26'],
['androidx.versionedparcelable:versionedparcelable:1.1.1',
'57e8d93260d18d5b9007c9eed3c64ad159de90c8609ebfc74a347cbd514535a4'],
['androidx.viewpager:viewpager:1.0.0',
'147af4e14a1984010d8f155e5e19d781f03c1d70dfed02a8e0d18428b8fc8682'],
['com.google.protobuf:protobuf-javalite:3.10.0',
'215a94dbe100130295906b531bb72a26965c7ac8fcd9a75bf8054a8ac2abf4b4'],
['org.jetbrains.kotlin:kotlin-stdlib-common:1.4.32',
'e1ff6f55ee9e7591dcc633f7757bac25a7edb1cc7f738b37ec652f10f66a4145'],
['org.jetbrains.kotlin:kotlin-stdlib:1.4.32',
'13e9fd3e69dc7230ce0fc873a92a4e5d521d179bcf1bef75a6705baac3bfecba'],
['org.jetbrains:annotations:13.0',
'ace2a10dc8e2d5fd34925ecac03e4988b2c0f851650c94b8cef49ba1bd111478'],
]
}