From 6777b3e0e6688a78696f35c41f7eb1121c9b7b06 Mon Sep 17 00:00:00 2001
From: Alan Evans <alan@signal.org>
Date: Fri, 17 May 2019 16:15:27 -0300
Subject: [PATCH] Image Editor - Undo button visibility.

---
 .../imageeditor/ImageEditorView.java          | 15 ++++
 .../imageeditor/UndoRedoStackListener.java    |  6 ++
 .../imageeditor/model/EditorElement.java      |  2 +-
 .../imageeditor/model/EditorModel.java        | 44 +++++++++---
 .../imageeditor/model/ElementStack.java       | 68 ++++++++++++-------
 .../imageeditor/model/UndoRedoStacks.java     | 48 +++++++++++--
 .../scribbles/ImageEditorFragment.java        |  9 ++-
 .../securesms/scribbles/ImageEditorHud.java   | 20 +++++-
 8 files changed, 170 insertions(+), 42 deletions(-)
 create mode 100644 src/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java

diff --git a/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java
index ed98f296e2..2d4f189a6d 100644
--- a/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java
+++ b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java
@@ -60,6 +60,9 @@ public final class ImageEditorView extends FrameLayout {
   @Nullable
   private DrawingChangedListener drawingChangedListener;
 
+  @Nullable
+  private UndoRedoStackListener undoRedoStackListener;
+
   private final Matrix viewMatrix      = new Matrix();
   private final RectF  viewPort        = Bounds.newFullBounds();
   private final RectF  visibleViewPort = Bounds.newFullBounds();
@@ -200,9 +203,11 @@ public final class ImageEditorView extends FrameLayout {
     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();
     }
@@ -386,6 +391,10 @@ public final class ImageEditorView extends FrameLayout {
     this.drawingChangedListener = drawingChangedListener;
   }
 
+  public void setUndoRedoStackListener(@Nullable UndoRedoStackListener undoRedoStackListener) {
+    this.undoRedoStackListener = undoRedoStackListener;
+  }
+
   public void setTapListener(TapListener tapListener) {
     this.tapListener = tapListener;
   }
@@ -398,6 +407,12 @@ public final class ImageEditorView extends FrameLayout {
     }
   }
 
+  private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) {
+    if (undoRedoStackListener != null) {
+      undoRedoStackListener.onAvailabilityChanged(undoAvailable, redoAvailable);
+    }
+  }
+
   private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener {
 
     @Override
diff --git a/src/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java b/src/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java
new file mode 100644
index 0000000000..6f7b5f11c9
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java
@@ -0,0 +1,6 @@
+package org.thoughtcrime.securesms.imageeditor;
+
+public interface UndoRedoStackListener {
+
+  void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable);
+}
diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java
index f8b7f3de2c..2f2312c9f6 100644
--- a/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java
+++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java
@@ -44,7 +44,7 @@ public final class EditorElement implements Parcelable {
 
   private final Matrix tempMatrix = new Matrix();
 
-  private final List<EditorElement> children = new LinkedList<>();
+  private final List<EditorElement> children        = new LinkedList<>();
   private final List<EditorElement> deletedChildren = new LinkedList<>();
 
   @NonNull
diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java
index fde68048c1..c85dd89439 100644
--- a/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java
+++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.imageeditor.Bounds;
 import org.thoughtcrime.securesms.imageeditor.ColorableRenderer;
 import org.thoughtcrime.securesms.imageeditor.Renderer;
 import org.thoughtcrime.securesms.imageeditor.RendererContext;
+import org.thoughtcrime.securesms.imageeditor.UndoRedoStackListener;
 
 import java.util.HashMap;
 import java.util.LinkedHashSet;
@@ -42,6 +43,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
   @NonNull
   private Runnable invalidate = NULL_RUNNABLE;
 
+  private UndoRedoStackListener undoRedoStackListener;
+
   private final UndoRedoStacks undoRedoStacks;
   private final UndoRedoStacks cropUndoRedoStacks;
   private final InBoundsMemory inBoundsMemory = new InBoundsMemory();
@@ -70,6 +73,10 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
     this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE;
   }
 
+  public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) {
+    this.undoRedoStackListener = undoRedoStackListener;
+  }
+
   /**
    * Renders tree with the following matrix:
    * <p>
@@ -117,9 +124,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
 
     UndoRedoStacks stacks = cropping ? cropUndoRedoStacks : undoRedoStacks;
 
-    if (stacks.getUndoStack().tryPush(editorElementHierarchy.getRoot())) {
-      stacks.getRedoStack().clear();
-    }
+    stacks.pushState(editorElementHierarchy.getRoot());
   }
 
   public void undo() {
@@ -127,6 +132,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
     UndoRedoStacks stacks   = cropping ? cropUndoRedoStacks : undoRedoStacks;
 
     undoRedo(stacks.getUndoStack(), stacks.getRedoStack(), cropping);
+
+    updateUndoRedoAvailableState(stacks);
   }
 
   public void redo() {
@@ -134,12 +141,15 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
     UndoRedoStacks stacks   = cropping ? cropUndoRedoStacks : undoRedoStacks;
 
     undoRedo(stacks.getRedoStack(), stacks.getUndoStack(), cropping);
+
+    updateUndoRedoAvailableState(stacks);
   }
 
   private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack, boolean keepEditorState) {
-    final EditorElement popped = fromStack.pop();
+    final EditorElement oldRootElement = editorElementHierarchy.getRoot();
+    final EditorElement popped         = fromStack.pop(oldRootElement);
+
     if (popped != null) {
-      EditorElement oldRootElement = editorElementHierarchy.getRoot();
       editorElementHierarchy = EditorElementHierarchy.create(popped);
       toStack.tryPush(oldRootElement);
 
@@ -187,6 +197,14 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
     }
   }
 
+  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);
@@ -195,14 +213,15 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
 
   public void startCrop() {
     pushUndoPoint();
-    cropUndoRedoStacks.getUndoStack().clear();
-    cropUndoRedoStacks.getUndoStack().clear();
+    cropUndoRedoStacks.clear(editorElementHierarchy.getRoot());
     editorElementHierarchy.startCrop(invalidate);
     inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
+    updateUndoRedoAvailableState(cropUndoRedoStacks);
   }
 
   public void doneCrop() {
     editorElementHierarchy.doneCrop(visibleViewPort, invalidate);
+    updateUndoRedoAvailableState(undoRedoStacks);
   }
 
   public void setCropAspectLock(boolean locked) {
@@ -223,6 +242,9 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
     if (isCropping()) {
       ensureFitsBounds(allowScaleToRepairCrop);
     }
+
+    UndoRedoStacks stacks = isCropping() ? cropUndoRedoStacks : undoRedoStacks;
+    updateUndoRedoAvailableState(stacks);
   }
 
   private void ensureFitsBounds(boolean allowScaleToRepairCrop) {
@@ -467,13 +489,14 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
     parent.addElement(element);
 
     if (parent != mainImage) {
-      undoRedoStacks.getUndoStack().clear();
+      undoRedoStacks.clear(editorElementHierarchy.getRoot());
     }
+
+    updateUndoRedoAvailableState(undoRedoStacks);
   }
 
   public boolean isChanged() {
-    ElementStack undoStack = undoRedoStacks.getUndoStack();
-    return !undoStack.isEmpty() || undoStack.isOverflowed();
+    return undoRedoStacks.isChanged(editorElementHierarchy.getRoot());
   }
 
   public RectF findCropRelativeToRoot() {
@@ -578,4 +601,5 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
   public boolean isCropping() {
     return editorElementHierarchy.getCropEditorElement().getFlags().isVisible();
   }
+
 }
diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java b/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java
index 3b0a1b5887..15e0516143 100644
--- a/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java
+++ b/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java
@@ -13,14 +13,12 @@ import java.util.Stack;
  * <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 the {@link #overflowed} flag is set.
- * So that when used as an undo stack, {@link #isEmpty()} and {@link #isOverflowed()} tell you if the image has ever changed.
+ * The stack has a {@link #limit} and if it exceeds that limit during a push the earliest item is removed.
  */
 final class ElementStack implements Parcelable {
 
   private final int           limit;
   private final Stack<byte[]> stack = new Stack<>();
-  private       boolean       overflowed;
 
   ElementStack(int limit) {
     this.limit = limit;
@@ -28,7 +26,6 @@ final class ElementStack implements Parcelable {
 
   private ElementStack(@NonNull Parcel in) {
     this(in.readInt());
-    overflowed = in.readInt() != 0;
     final int count = in.readInt();
     for (int i = 0; i < count; i++) {
       stack.add(i, in.createByteArray());
@@ -43,32 +40,52 @@ final class ElementStack implements Parcelable {
    * @return true iff the pushed item was different to the top item.
    */
   boolean tryPush(@NonNull EditorElement element) {
-    Parcel parcel = Parcel.obtain();
-    byte[] bytes;
-    try {
-      parcel.writeParcelable(element, 0);
-      bytes = parcel.marshall();
-    } finally {
-      parcel.recycle();
-    }
-    boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek());
+    byte[]  bytes = getBytes(element);
+    boolean push  = stack.isEmpty() || !Arrays.equals(bytes, stack.peek());
+
     if (push) {
       stack.push(bytes);
       if (stack.size() > limit) {
         stack.remove(0);
-        overflowed = true;
       }
     }
     return push;
   }
 
-  @Nullable EditorElement pop() {
+  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[] data = stack.pop();
+    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(data, 0, data.length);
+      parcel.unmarshall(stackData, 0, stackData.length);
       parcel.setDataPosition(0);
       return parcel.readParcelable(EditorElement.class.getClassLoader());
     } finally {
@@ -100,7 +117,6 @@ final class ElementStack implements Parcelable {
   @Override
   public void writeToParcel(Parcel dest, int flags) {
     dest.writeInt(limit);
-    dest.writeInt(overflowed ? 1 : 0);
     final int count = stack.size();
     dest.writeInt(count);
     for (int i = 0; i < count; i++) {
@@ -108,11 +124,17 @@ final class ElementStack implements Parcelable {
     }
   }
 
-  boolean isEmpty() {
-    return stack.isEmpty();
-  }
+  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;
+      }
+    }
 
-  boolean isOverflowed() {
-    return overflowed;
+    return false;
   }
 }
diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java b/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java
index c0820e0d6e..d7a1481ee4 100644
--- a/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java
+++ b/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java
@@ -2,19 +2,27 @@ package org.thoughtcrime.securesms.imageeditor.model;
 
 import android.os.Parcel;
 import android.os.Parcelable;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import java.util.Arrays;
 
 final class UndoRedoStacks implements Parcelable {
 
   private final ElementStack undoStack;
   private final ElementStack redoStack;
 
-  public UndoRedoStacks(int limit) {
-    this(new ElementStack(limit), new ElementStack(limit));
+  @NonNull
+  private byte[] unchangedState;
+
+  UndoRedoStacks(int limit) {
+    this(new ElementStack(limit), new ElementStack(limit), null);
   }
 
-  private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack) {
+  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>() {
@@ -22,7 +30,8 @@ final class UndoRedoStacks implements Parcelable {
     public UndoRedoStacks createFromParcel(Parcel in) {
       return new UndoRedoStacks(
       in.readParcelable(ElementStack.class.getClassLoader()),
-      in.readParcelable(ElementStack.class.getClassLoader())
+      in.readParcelable(ElementStack.class.getClassLoader()),
+      in.createByteArray()
       );
     }
 
@@ -36,6 +45,7 @@ final class UndoRedoStacks implements Parcelable {
   public void writeToParcel(Parcel dest, int flags) {
     dest.writeParcelable(undoStack, flags);
     dest.writeParcelable(redoStack, flags);
+    dest.writeByteArray(unchangedState);
   }
 
   @Override
@@ -50,4 +60,34 @@ final class UndoRedoStacks implements Parcelable {
   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);
+  }
 }
diff --git a/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java
index 2b00d2f51e..5862d59483 100644
--- a/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java
+++ b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java
@@ -118,13 +118,14 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
   public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
     super.onViewCreated(view, savedInstanceState);
 
-    imageEditorHud = view.findViewById(R.id.scribble_hud);
-    imageEditorView  = view.findViewById(R.id.image_editor_view);
+    imageEditorHud  = view.findViewById(R.id.scribble_hud);
+    imageEditorView = view.findViewById(R.id.image_editor_view);
 
     imageEditorHud.setEventListener(this);
 
     imageEditorView.setTapListener(selectionListener);
     imageEditorView.setDrawingChangedListener(this::refreshUniqueColors);
+    imageEditorView.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged);
 
     EditorModel editorModel = null;
 
@@ -321,6 +322,10 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
     imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha());
   }
 
+  private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) {
+    imageEditorHud.setUndoAvailability(undoAvailable);
+  }
+
    private final ImageEditorView.TapListener selectionListener = new ImageEditorView.TapListener() {
 
      @Override
diff --git a/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java b/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java
index c00a753e3c..50299db495 100644
--- a/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java
+++ b/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java
@@ -47,7 +47,10 @@ public final class ImageEditorHud extends LinearLayout {
   private ColorPaletteAdapter        colorPaletteAdapter;
 
   private final Map<Mode, Set<View>> visibilityModeMap = new HashMap<>();
-  private final Set<View> allViews = new HashSet<>();
+  private final Set<View>            allViews          = new HashSet<>();
+
+  private Mode    currentMode;
+  private boolean undoAvailable;
 
   public ImageEditorHud(@NonNull Context context) {
     super(context);
@@ -171,9 +174,10 @@ public final class ImageEditorHud extends LinearLayout {
   }
 
   private void setMode(@NonNull Mode mode, boolean notify) {
+    this.currentMode = mode;
     Set<View> visibleButtons = visibilityModeMap.get(mode);
     for (View button : allViews) {
-      button.setVisibility(visibleButtons != null && visibleButtons.contains(button) ? VISIBLE : GONE);
+      button.setVisibility(buttonIsVisible(visibleButtons, button) ? VISIBLE : GONE);
     }
 
     switch (mode) {
@@ -189,6 +193,12 @@ public final class ImageEditorHud extends LinearLayout {
     eventListener.onRequestFullScreen(mode != Mode.NONE);
   }
 
+  private boolean buttonIsVisible(@Nullable Set<View> visibleButtons, @NonNull View button) {
+    return visibleButtons != null &&
+           visibleButtons.contains(button) &&
+           (button != undoButton || undoAvailable);
+  }
+
   private void presentModeCrop() {
     updateCropAspectLockImage(eventListener.isCropAspectLocked());
   }
@@ -216,6 +226,12 @@ public final class ImageEditorHud extends LinearLayout {
     return color & ~0xff000000 | 0x80000000;
   }
 
+  public void setUndoAvailability(boolean undoAvailable) {
+    this.undoAvailable = undoAvailable;
+
+    undoButton.setVisibility(buttonIsVisible(visibilityModeMap.get(currentMode), undoButton) ? VISIBLE : GONE);
+  }
+
   public enum Mode {
     NONE, DRAW, HIGHLIGHT, TEXT, MOVE_DELETE, CROP
   }