From d409278dd530b521ec1504ef33d323115febd0ac Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 4 Apr 2022 11:38:15 -0400 Subject: [PATCH] Do not allow emoji in image editing if device doesn't support it. --- .../securesms/components/emoji/EmojiUtil.java | 9 +++++ .../scribbles/ImageEditorFragment.java | 8 ++++ .../scribbles/RemoveEmojiTextFilter.java | 13 ++++++ .../scribbles/TextEntryDialogFragment.kt | 3 ++ .../java/org/signal/core/util/FontUtil.kt | 40 +++++++++++++++++++ .../java/org/signal/core/util/ListUtil.java | 13 ++++++ image-editor/app/build.gradle | 1 + .../signal/imageeditor/app/MainActivity.java | 2 + .../imageeditor/core/HiddenEditText.java | 31 +++++++++++++- .../imageeditor/core/ImageEditorView.java | 17 ++++++++ 10 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/scribbles/RemoveEmojiTextFilter.java create mode 100644 core-util/src/main/java/org/signal/core/util/FontUtil.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java index 3c46d965d..6eccac37b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java @@ -18,6 +18,7 @@ import java.util.regex.Pattern; public final class EmojiUtil { private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]+)+)+$"); + private static final String EMOJI_REGEX = "[^\\p{L}\\p{M}\\p{N}\\p{P}\\p{Z}\\p{Cf}\\p{Cs}\\s]"; private EmojiUtil() {} @@ -84,4 +85,12 @@ public final class EmojiUtil { return (candidates != null && candidates.size() > 0) || EMOJI_PATTERN.matcher(text).matches(); } + + public static String stripEmoji(@Nullable String text) { + if (text == null) { + return text; + } + + return text.replaceAll(EMOJI_REGEX, ""); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index b2768faed..c6104dd77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -29,10 +29,12 @@ import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import org.signal.core.util.FontUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.imageeditor.core.Bounds; import org.signal.imageeditor.core.ColorableRenderer; +import org.signal.imageeditor.core.HiddenEditText; import org.signal.imageeditor.core.ImageEditorView; import org.signal.imageeditor.core.Renderer; import org.signal.imageeditor.core.SelectableRenderer; @@ -44,6 +46,7 @@ import org.signal.imageeditor.core.renderers.MultiLineTextRenderer; import org.signal.libsignal.protocol.util.Pair; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.animation.ResizeAnimation; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.fonts.FontTypefaceProvider; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -78,6 +81,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private static final String TAG = Log.tag(ImageEditorFragment.class); + public static final boolean CAN_RENDER_EMOJI = FontUtil.canRenderEmojiAtFontSize(1024); + private static final float PORTRAIT_ASPECT_RATIO = 9 / 16f; private static final String KEY_IMAGE_URI = "image_uri"; @@ -220,6 +225,9 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu imageEditorView = view.findViewById(R.id.image_editor_view); imageEditorView.setTypefaceProvider(new FontTypefaceProvider()); + if (!CAN_RENDER_EMOJI) { + imageEditorView.addTextInputFilter(new RemoveEmojiTextFilter()); + } int width = getResources().getDisplayMetrics().widthPixels; int height = (int) ((16 / 9f) * width); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/RemoveEmojiTextFilter.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/RemoveEmojiTextFilter.java new file mode 100644 index 000000000..b646d1408 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/RemoveEmojiTextFilter.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.scribbles; + +import androidx.annotation.NonNull; + +import org.signal.imageeditor.core.HiddenEditText; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; + +class RemoveEmojiTextFilter implements HiddenEditText.TextFilter { + @Override + public String filter(@NonNull String text) { + return EmojiUtil.stripEmoji(text); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/TextEntryDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/TextEntryDialogFragment.kt index be08a3c18..b37e7f704 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/TextEntryDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/TextEntryDialogFragment.kt @@ -33,6 +33,9 @@ class TextEntryDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_im controller = requireListener() hiddenTextEntry = HiddenEditText(requireContext()) + if (!ImageEditorFragment.CAN_RENDER_EMOJI) { + hiddenTextEntry.addTextFilter(RemoveEmojiTextFilter()) + } (view as ViewGroup).addView(hiddenTextEntry) view.setOnClickListener { diff --git a/core-util/src/main/java/org/signal/core/util/FontUtil.kt b/core-util/src/main/java/org/signal/core/util/FontUtil.kt new file mode 100644 index 000000000..1cc9492b9 --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/FontUtil.kt @@ -0,0 +1,40 @@ +package org.signal.core.util + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import kotlin.math.abs + + +object FontUtil { + private const val SAMPLE_EMOJI = "\uD83C\uDF0D" // 🌍 + + /** + * Certain platforms cannot render emoji above a certain font size. + * + * This will attempt to render an emoji at the specified font size and tell you if it's possible. + * It does this by rendering an emoji into a 1x1 bitmap and seeing if the resulting pixel is non-transparent. + * + * https://stackoverflow.com/a/50988748 + */ + @JvmStatic + fun canRenderEmojiAtFontSize(size: Float): Boolean { + val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint() + + paint.textSize = size + paint.textAlign = Paint.Align.CENTER + + val ascent: Float = abs(paint.ascent()) + val descent: Float = abs(paint.descent()) + val halfHeight = (ascent + descent) / 2.0f + + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + canvas.drawText(SAMPLE_EMOJI, 0.5f, 0.5f + halfHeight - descent, paint) + + return bitmap.getPixel(0, 0) != 0 + } +} \ No newline at end of file diff --git a/core-util/src/main/java/org/signal/core/util/ListUtil.java b/core-util/src/main/java/org/signal/core/util/ListUtil.java index 2e318bec8..9669ccdb5 100644 --- a/core-util/src/main/java/org/signal/core/util/ListUtil.java +++ b/core-util/src/main/java/org/signal/core/util/ListUtil.java @@ -3,7 +3,9 @@ package org.signal.core.util; import androidx.annotation.NonNull; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.stream.Stream; public final class ListUtil { private ListUtil() {} @@ -18,4 +20,15 @@ public final class ListUtil { return chunks; } + + @SafeVarargs + public static List concat(Collection... items) { + final List concat = new ArrayList<>(Stream.of(items).map(Collection::size).reduce(0, Integer::sum)); + + for (Collection list : items) { + concat.addAll(list); + } + + return concat; + } } diff --git a/image-editor/app/build.gradle b/image-editor/app/build.gradle index 800968601..43ceaeba3 100644 --- a/image-editor/app/build.gradle +++ b/image-editor/app/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation libs.androidx.core.ktx implementation libs.androidx.appcompat implementation libs.material.material + implementation project(':core-util') implementation project(':image-editor') implementation libs.glide.glide diff --git a/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java b/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java index 5bd54df02..05d24a546 100644 --- a/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java +++ b/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java @@ -70,6 +70,8 @@ public final class MainActivity extends AppCompatActivity { imageEditorView = findViewById(R.id.image_editor); + imageEditorView.setTypefaceProvider(typefaceProvider); + imageEditorView.setUndoRedoStackListener((undoAvailable, redoAvailable) -> { Log.d("ALAN", String.format("Undo/Redo available: %s, %s", undoAvailable ? "Y" : "N", redoAvailable ? "Y" : "N")); if (menu == null) return; diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/HiddenEditText.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/HiddenEditText.java index d7f88d9c1..e34d1722d 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/HiddenEditText.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/HiddenEditText.java @@ -17,6 +17,10 @@ import androidx.annotation.Nullable; import org.signal.imageeditor.core.model.EditorElement; import org.signal.imageeditor.core.renderers.MultiLineTextRenderer; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + /** * Invisible {@link android.widget.EditText} that is used during in-image text editing. */ @@ -37,6 +41,8 @@ public final class HiddenEditText extends androidx.appcompat.widget.AppCompatEdi @Nullable private OnEditOrSelectionChange onEditOrSelectionChange; + private List textFilters = new LinkedList<>(); + public HiddenEditText(Context context) { super(context); setAlpha(0); @@ -54,7 +60,11 @@ public final class HiddenEditText extends androidx.appcompat.widget.AppCompatEdi protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { super.onTextChanged(text, start, lengthBefore, lengthAfter); if (currentTextEntity != null) { - currentTextEntity.setText(text.toString()); + String filtered = text.toString(); + for (TextFilter filter : textFilters) { + filtered = filter.filter(filtered); + } + currentTextEntity.setText(filtered); postEditOrSelectionChange(); } } @@ -79,6 +89,18 @@ public final class HiddenEditText extends androidx.appcompat.widget.AppCompatEdi } } + public void addTextFilter(@NonNull TextFilter filter) { + textFilters.add(filter); + } + + public void addTextFilters(@NonNull Collection filters) { + textFilters.addAll(filters); + } + + public void removeTextFilter(@NonNull TextFilter filter) { + textFilters.remove(filter); + } + private void endEdit() { if (onEndEdit != null) { onEndEdit.run(); @@ -173,4 +195,11 @@ public final class HiddenEditText extends androidx.appcompat.widget.AppCompatEdi public interface OnEditOrSelectionChange { void onChange(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer); } + + public interface TextFilter { + /** + * Given an input string, return a filtered version. + */ + String filter(@NonNull String text); + } } diff --git a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java index 0bd0065e4..06913753a 100644 --- a/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java +++ b/image-editor/lib/src/main/java/org/signal/imageeditor/core/ImageEditorView.java @@ -24,6 +24,9 @@ import org.signal.imageeditor.core.renderers.BezierDrawingRenderer; import org.signal.imageeditor.core.renderers.MultiLineTextRenderer; import org.signal.imageeditor.core.renderers.TrashRenderer; +import java.util.LinkedList; +import java.util.List; + /** * ImageEditorView *

@@ -71,6 +74,8 @@ public final class ImageEditorView extends FrameLayout { @Nullable private DragListener dragListener; + private final List textFilters = new LinkedList<>(); + private final Matrix viewMatrix = new Matrix(); private final RectF viewPort = Bounds.newFullBounds(); private final RectF visibleViewPort = Bounds.newFullBounds(); @@ -116,6 +121,8 @@ public final class ImageEditorView extends FrameLayout { editText.clearFocus(); editText.setOnEndEdit(this::doneTextEditing); editText.setOnEditOrSelectionChange(this::zoomToFitText); + editText.addTextFilters(textFilters); + return editText; } @@ -150,6 +157,16 @@ public final class ImageEditorView extends FrameLayout { this.typefaceProvider = typefaceProvider; } + public void addTextInputFilter(@NonNull HiddenEditText.TextFilter inputFilter) { + textFilters.add(inputFilter); + editText = createAHiddenTextEntryField(); + } + + public void removeTextInputFilter(@NonNull HiddenEditText.TextFilter inputFilter) { + textFilters.remove(inputFilter); + editText = createAHiddenTextEntryField(); + } + @Override protected void onDraw(Canvas canvas) { if (rendererContext == null || rendererContext.canvas != canvas || rendererContext.typefaceProvider != typefaceProvider) {