/* * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.support.text.emoji; import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP; import android.os.Build; import android.support.annotation.AnyThread; import android.support.annotation.IntDef; import android.support.annotation.IntRange; import android.support.annotation.NonNull; import android.support.annotation.RequiresApi; import android.support.annotation.RestrictTo; import android.support.text.emoji.widget.SpannableBuilder; import android.support.v4.graphics.PaintCompat; import android.support.v4.util.Preconditions; import android.text.Editable; import android.text.Selection; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.TextPaint; import android.text.method.KeyListener; import android.text.method.MetaKeyKeyListener; import android.view.KeyEvent; import android.view.inputmethod.InputConnection; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * Processes the CharSequence and adds the emojis. * * @hide */ @AnyThread @RestrictTo(LIBRARY_GROUP) @RequiresApi(19) final class EmojiProcessor { /** * State transition commands. */ @IntDef({ACTION_ADVANCE_BOTH, ACTION_ADVANCE_END, ACTION_FLUSH}) @Retention(RetentionPolicy.SOURCE) @interface Action { } /** * Advance the end pointer in CharSequence and reset the start to be the end. */ private static final int ACTION_ADVANCE_BOTH = 1; /** * Advance end pointer in CharSequence. */ private static final int ACTION_ADVANCE_END = 2; /** * Add a new emoji with the metadata in {@link ProcessorSm#getFlushMetadata()}. Advance end * pointer in CharSequence and reset the start to be the end. */ private static final int ACTION_FLUSH = 3; /** * @hide */ @RestrictTo(LIBRARY_GROUP) static final int EMOJI_COUNT_UNLIMITED = Integer.MAX_VALUE; /** * Factory used to create EmojiSpans. */ private final EmojiCompat.SpanFactory mSpanFactory; /** * Emoji metadata repository. */ private final MetadataRepo mMetadataRepo; /** * Utility class that checks if the system can render a given glyph. */ private GlyphChecker mGlyphChecker = new GlyphChecker(); EmojiProcessor(@NonNull final MetadataRepo metadataRepo, @NonNull final EmojiCompat.SpanFactory spanFactory) { mSpanFactory = spanFactory; mMetadataRepo = metadataRepo; } EmojiMetadata getEmojiMetadata(@NonNull final CharSequence charSequence) { final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode()); final int end = charSequence.length(); int currentOffset = 0; while (currentOffset < end) { final int codePoint = Character.codePointAt(charSequence, currentOffset); final int action = sm.check(codePoint); if (action != ACTION_ADVANCE_END) { return null; } currentOffset += Character.charCount(codePoint); } if (sm.isInFlushableState()) { return sm.getCurrentMetadata(); } return null; } /** * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found. *

*

* * @param charSequence CharSequence to add the EmojiSpans, cannot be {@code null} * @param start start index in the charSequence to look for emojis, should be greater than or * equal to {@code 0}, also less than {@code charSequence.length()} * @param end end index in the charSequence to look for emojis, should be greater than or * equal to {@code start} parameter, also less than {@code charSequence.length()} * @param maxEmojiCount maximum number of emojis in the {@code charSequence}, should be greater * than or equal to {@code 0} * @param replaceAll whether to replace all emoji with {@link EmojiSpan}s */ CharSequence process(@NonNull final CharSequence charSequence, @IntRange(from = 0) int start, @IntRange(from = 0) int end, @IntRange(from = 0) int maxEmojiCount, final boolean replaceAll) { final boolean isSpannableBuilder = charSequence instanceof SpannableBuilder; if (isSpannableBuilder) { ((SpannableBuilder) charSequence).beginBatchEdit(); } try { Spannable spannable = null; // if it is a spannable already, use the same instance to add/remove EmojiSpans. // otherwise wait until the the first EmojiSpan found in order to change the result // into a Spannable. if (isSpannableBuilder || charSequence instanceof Spannable) { spannable = (Spannable) charSequence; } if (spannable != null) { final EmojiSpan[] spans = spannable.getSpans(start, end, EmojiSpan.class); if (spans != null && spans.length > 0) { // remove existing spans, and realign the start, end according to spans // if start or end is in the middle of an emoji they should be aligned final int length = spans.length; for (int index = 0; index < length; index++) { final EmojiSpan span = spans[index]; final int spanStart = spannable.getSpanStart(span); final int spanEnd = spannable.getSpanEnd(span); // Remove span only when its spanStart is NOT equal to current end. // During add operation an emoji at index 0 is added with 0-1 as start and // end indices. Therefore if there are emoji spans at [0-1] and [1-2] // and end is 1, the span between 0-1 should be deleted, not 1-2. if (spanStart != end) { spannable.removeSpan(span); } start = Math.min(spanStart, start); end = Math.max(spanEnd, end); } } } if (start == end || start >= charSequence.length()) { return charSequence; } // calculate max number of emojis that can be added. since getSpans call is a relatively // expensive operation, do it only when maxEmojiCount is not unlimited. if (maxEmojiCount != EMOJI_COUNT_UNLIMITED && spannable != null) { maxEmojiCount -= spannable.getSpans(0, spannable.length(), EmojiSpan.class).length; } // add new ones int addedCount = 0; final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode()); int currentOffset = start; int codePoint = Character.codePointAt(charSequence, currentOffset); while (currentOffset < end && addedCount < maxEmojiCount) { final int action = sm.check(codePoint); switch (action) { case ACTION_ADVANCE_BOTH: currentOffset += Character.charCount(codePoint); start = currentOffset; if (currentOffset < end) { codePoint = Character.codePointAt(charSequence, currentOffset); } break; case ACTION_ADVANCE_END: currentOffset += Character.charCount(codePoint); if (currentOffset < end) { codePoint = Character.codePointAt(charSequence, currentOffset); } break; case ACTION_FLUSH: if (replaceAll || !hasGlyph(charSequence, start, currentOffset, sm.getFlushMetadata())) { if (spannable == null) { spannable = new SpannableString(charSequence); } addEmoji(spannable, sm.getFlushMetadata(), start, currentOffset); addedCount++; } start = currentOffset; break; } } // After the last codepoint is consumed the state machine might be in a state where it // identified an emoji before. i.e. abc[women-emoji] when the last codepoint is consumed // state machine is waiting to see if there is an emoji sequence (i.e. ZWJ). // Need to check if it is in such a state. if (sm.isInFlushableState() && addedCount < maxEmojiCount) { if (replaceAll || !hasGlyph(charSequence, start, currentOffset, sm.getCurrentMetadata())) { if (spannable == null) { spannable = new SpannableString(charSequence); } addEmoji(spannable, sm.getCurrentMetadata(), start, currentOffset); addedCount++; } } return spannable == null ? charSequence : spannable; } finally { if (isSpannableBuilder) { ((SpannableBuilder) charSequence).endBatchEdit(); } } } /** * Handles onKeyDown commands from a {@link KeyListener} and if {@code keyCode} is one of * {@link KeyEvent#KEYCODE_DEL} or {@link KeyEvent#KEYCODE_FORWARD_DEL} it tries to delete an * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is * deleted with the characters it covers. *

* If there is a selection where selection start is not equal to selection end, does not * delete. * * @param editable Editable instance passed to {@link KeyListener#onKeyDown(android.view.View, * Editable, int, KeyEvent)} * @param keyCode keyCode passed to {@link KeyListener#onKeyDown(android.view.View, Editable, * int, KeyEvent)} * @param event KeyEvent passed to {@link KeyListener#onKeyDown(android.view.View, Editable, * int, KeyEvent)} * * @return {@code true} if an {@link EmojiSpan} is deleted */ static boolean handleOnKeyDown(@NonNull final Editable editable, final int keyCode, final KeyEvent event) { final boolean handled; switch (keyCode) { case KeyEvent.KEYCODE_DEL: handled = delete(editable, event, false /*forwardDelete*/); break; case KeyEvent.KEYCODE_FORWARD_DEL: handled = delete(editable, event, true /*forwardDelete*/); break; default: handled = false; break; } if (handled) { MetaKeyKeyListener.adjustMetaAfterKeypress(editable); return true; } return false; } private static boolean delete(final Editable content, final KeyEvent event, final boolean forwardDelete) { if (hasModifiers(event)) { return false; } final int start = Selection.getSelectionStart(content); final int end = Selection.getSelectionEnd(content); if (hasInvalidSelection(start, end)) { return false; } final EmojiSpan[] spans = content.getSpans(start, end, EmojiSpan.class); if (spans != null && spans.length > 0) { final int length = spans.length; for (int index = 0; index < length; index++) { final EmojiSpan span = spans[index]; final int spanStart = content.getSpanStart(span); final int spanEnd = content.getSpanEnd(span); if ((forwardDelete && spanStart == start) || (!forwardDelete && spanEnd == start) || (start > spanStart && start < spanEnd)) { content.delete(spanStart, spanEnd); return true; } } } return false; } /** * Handles deleteSurroundingText commands from {@link InputConnection} and tries to delete an * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is * deleted. *

* If there is a selection where selection start is not equal to selection end, does not * delete. * * @param inputConnection InputConnection instance * @param editable TextView.Editable instance * @param beforeLength the number of characters before the cursor to be deleted * @param afterLength the number of characters after the cursor to be deleted * @param inCodePoints {@code true} if length parameters are in codepoints * * @return {@code true} if an {@link EmojiSpan} is deleted */ static boolean handleDeleteSurroundingText(@NonNull final InputConnection inputConnection, @NonNull final Editable editable, @IntRange(from = 0) final int beforeLength, @IntRange(from = 0) final int afterLength, final boolean inCodePoints) { if (editable == null || inputConnection == null) { return false; } if (beforeLength < 0 || afterLength < 0) { return false; } final int selectionStart = Selection.getSelectionStart(editable); final int selectionEnd = Selection.getSelectionEnd(editable); if (hasInvalidSelection(selectionStart, selectionEnd)) { return false; } int start; int end; if (inCodePoints) { // go backwards in terms of codepoints start = CodepointIndexFinder.findIndexBackward(editable, selectionStart, Math.max(beforeLength, 0)); end = CodepointIndexFinder.findIndexForward(editable, selectionEnd, Math.max(afterLength, 0)); if (start == CodepointIndexFinder.INVALID_INDEX || end == CodepointIndexFinder.INVALID_INDEX) { return false; } } else { start = Math.max(selectionStart - beforeLength, 0); end = Math.min(selectionEnd + afterLength, editable.length()); } final EmojiSpan[] spans = editable.getSpans(start, end, EmojiSpan.class); if (spans != null && spans.length > 0) { final int length = spans.length; for (int index = 0; index < length; index++) { final EmojiSpan span = spans[index]; int spanStart = editable.getSpanStart(span); int spanEnd = editable.getSpanEnd(span); start = Math.min(spanStart, start); end = Math.max(spanEnd, end); } start = Math.max(start, 0); end = Math.min(end, editable.length()); inputConnection.beginBatchEdit(); editable.delete(start, end); inputConnection.endBatchEdit(); return true; } return false; } private static boolean hasInvalidSelection(final int start, final int end) { return start == -1 || end == -1 || start != end; } private static boolean hasModifiers(KeyEvent event) { return !KeyEvent.metaStateHasNoModifiers(event.getMetaState()); } private void addEmoji(@NonNull final Spannable spannable, final EmojiMetadata metadata, final int start, final int end) { final EmojiSpan span = mSpanFactory.createSpan(metadata); spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } /** * Checks whether the current OS can render a given emoji. Used by the system to decide if an * emoji span should be added. If the system cannot render it, an emoji span will be added. * Used only for the case where replaceAll is set to {@code false}. * * @param charSequence the CharSequence that the emoji is in * @param start start index of the emoji in the CharSequence * @param end end index of the emoji in the CharSequence * @param metadata EmojiMetadata instance for the emoji * * @return {@code true} if the OS can render emoji, {@code false} otherwise */ private boolean hasGlyph(final CharSequence charSequence, int start, final int end, final EmojiMetadata metadata) { // For pre M devices, heuristic in PaintCompat can result in false positives. we are // adding another heuristic using the sdkAdded field. if the emoji was added to OS // at a later version we assume that the system probably cannot render it. if (Build.VERSION.SDK_INT < 23 && metadata.getSdkAdded() > Build.VERSION.SDK_INT) { return false; } // if the existence is not calculated yet if (metadata.getHasGlyph() == EmojiMetadata.HAS_GLYPH_UNKNOWN) { final boolean hasGlyph = mGlyphChecker.hasGlyph(charSequence, start, end); metadata.setHasGlyph(hasGlyph); } return metadata.getHasGlyph() == EmojiMetadata.HAS_GLYPH_EXISTS; } /** * Set the GlyphChecker instance used by EmojiProcessor. Used for testing. */ void setGlyphChecker(@NonNull final GlyphChecker glyphChecker) { Preconditions.checkNotNull(glyphChecker); mGlyphChecker = glyphChecker; } /** * State machine for walking over the metadata trie. */ static final class ProcessorSm { private static final int STATE_DEFAULT = 1; private static final int STATE_WALKING = 2; private int mState = STATE_DEFAULT; /** * Root of the trie */ private final MetadataRepo.Node mRootNode; /** * Pointer to the node after last codepoint. */ private MetadataRepo.Node mCurrentNode; /** * The node where ACTION_FLUSH is called. Required since after flush action is * returned mCurrentNode is reset to be the root. */ private MetadataRepo.Node mFlushNode; /** * The code point that was checked. */ private int mLastCodepoint; /** * Level for mCurrentNode. Root is 0. */ private int mCurrentDepth; ProcessorSm(MetadataRepo.Node rootNode) { mRootNode = rootNode; mCurrentNode = rootNode; } @Action int check(final int codePoint) { final int action; MetadataRepo.Node node = mCurrentNode.get(codePoint); switch (mState) { case STATE_WALKING: if (node != null) { mCurrentNode = node; mCurrentDepth += 1; action = ACTION_ADVANCE_END; } else { if (isTextStyle(codePoint)) { action = reset(); } else if (isEmojiStyle(codePoint)) { action = ACTION_ADVANCE_END; } else if (mCurrentNode.getData() != null) { if (mCurrentDepth == 1) { if (mCurrentNode.getData().isDefaultEmoji() || isEmojiStyle(mLastCodepoint)) { mFlushNode = mCurrentNode; action = ACTION_FLUSH; reset(); } else { action = reset(); } } else { mFlushNode = mCurrentNode; action = ACTION_FLUSH; reset(); } } else { action = reset(); } } break; case STATE_DEFAULT: default: if (node == null) { action = reset(); } else { mState = STATE_WALKING; mCurrentNode = node; mCurrentDepth = 1; action = ACTION_ADVANCE_END; } break; } mLastCodepoint = codePoint; return action; } @Action private int reset() { mState = STATE_DEFAULT; mCurrentNode = mRootNode; mCurrentDepth = 0; return ACTION_ADVANCE_BOTH; } /** * @return the metadata node when ACTION_FLUSH is returned */ EmojiMetadata getFlushMetadata() { return mFlushNode.getData(); } /** * @return current pointer to the metadata node in the trie */ EmojiMetadata getCurrentMetadata() { return mCurrentNode.getData(); } /** * Need for the case where input is consumed, but action_flush was not called. For example * when the char sequence has single codepoint character which is a default emoji. State * machine will wait for the next. * * @return whether the current state requires an emoji to be added */ boolean isInFlushableState() { return mState == STATE_WALKING && mCurrentNode.getData() != null && (mCurrentNode.getData().isDefaultEmoji() || isEmojiStyle(mLastCodepoint) || mCurrentDepth > 1); } /** * @param codePoint CodePoint to check * * @return {@code true} if the codepoint is a emoji style standardized variation selector */ private static boolean isEmojiStyle(int codePoint) { return codePoint == 0xFE0F; } /** * @param codePoint CodePoint to check * * @return {@code true} if the codepoint is a text style standardized variation selector */ private static boolean isTextStyle(int codePoint) { return codePoint == 0xFE0E; } } /** * Copy of BaseInputConnection findIndexBackward and findIndexForward functions. */ private static final class CodepointIndexFinder { private static final int INVALID_INDEX = -1; /** * Find start index of the character in {@code cs} that is {@code numCodePoints} behind * starting from {@code from}. * * @param cs CharSequence to work on * @param from the index to start going backwards * @param numCodePoints the number of codepoints * * @return start index of the character */ private static int findIndexBackward(final CharSequence cs, final int from, final int numCodePoints) { int currentIndex = from; boolean waitingHighSurrogate = false; final int length = cs.length(); if (currentIndex < 0 || length < currentIndex) { return INVALID_INDEX; // The starting point is out of range. } if (numCodePoints < 0) { return INVALID_INDEX; // Basically this should not happen. } int remainingCodePoints = numCodePoints; while (true) { if (remainingCodePoints == 0) { return currentIndex; // Reached to the requested length in code points. } --currentIndex; if (currentIndex < 0) { if (waitingHighSurrogate) { return INVALID_INDEX; // An invalid surrogate pair is found. } return 0; // Reached to the beginning of the text w/o any invalid surrogate // pair. } final char c = cs.charAt(currentIndex); if (waitingHighSurrogate) { if (!Character.isHighSurrogate(c)) { return INVALID_INDEX; // An invalid surrogate pair is found. } waitingHighSurrogate = false; --remainingCodePoints; continue; } if (!Character.isSurrogate(c)) { --remainingCodePoints; continue; } if (Character.isHighSurrogate(c)) { return INVALID_INDEX; // A invalid surrogate pair is found. } waitingHighSurrogate = true; } } /** * Find start index of the character in {@code cs} that is {@code numCodePoints} ahead * starting from {@code from}. * * @param cs CharSequence to work on * @param from the index to start going forward * @param numCodePoints the number of codepoints * * @return start index of the character */ private static int findIndexForward(final CharSequence cs, final int from, final int numCodePoints) { int currentIndex = from; boolean waitingLowSurrogate = false; final int length = cs.length(); if (currentIndex < 0 || length < currentIndex) { return INVALID_INDEX; // The starting point is out of range. } if (numCodePoints < 0) { return INVALID_INDEX; // Basically this should not happen. } int remainingCodePoints = numCodePoints; while (true) { if (remainingCodePoints == 0) { return currentIndex; // Reached to the requested length in code points. } if (currentIndex >= length) { if (waitingLowSurrogate) { return INVALID_INDEX; // An invalid surrogate pair is found. } return length; // Reached to the end of the text w/o any invalid surrogate // pair. } final char c = cs.charAt(currentIndex); if (waitingLowSurrogate) { if (!Character.isLowSurrogate(c)) { return INVALID_INDEX; // An invalid surrogate pair is found. } --remainingCodePoints; waitingLowSurrogate = false; ++currentIndex; continue; } if (!Character.isSurrogate(c)) { --remainingCodePoints; ++currentIndex; continue; } if (Character.isLowSurrogate(c)) { return INVALID_INDEX; // A invalid surrogate pair is found. } waitingLowSurrogate = true; ++currentIndex; } } } /** * Utility class that checks if the system can render a given glyph. * * @hide */ @AnyThread @RestrictTo(LIBRARY_GROUP) public static class GlyphChecker { /** * Default text size for {@link #mTextPaint}. */ private static final int PAINT_TEXT_SIZE = 10; /** * Used to create strings required by * {@link PaintCompat#hasGlyph(android.graphics.Paint, String)}. */ private static final ThreadLocal sStringBuilder = new ThreadLocal<>(); /** * TextPaint used during {@link PaintCompat#hasGlyph(android.graphics.Paint, String)} check. */ private final TextPaint mTextPaint; GlyphChecker() { mTextPaint = new TextPaint(); mTextPaint.setTextSize(PAINT_TEXT_SIZE); } /** * Returns whether the system can render an emoji. * * @param charSequence the CharSequence that the emoji is in * @param start start index of the emoji in the CharSequence * @param end end index of the emoji in the CharSequence * * @return {@code true} if the OS can render emoji, {@code false} otherwise */ public boolean hasGlyph(final CharSequence charSequence, int start, final int end) { final StringBuilder builder = getStringBuilder(); builder.setLength(0); while (start < end) { builder.append(charSequence.charAt(start)); start++; } return PaintCompat.hasGlyph(mTextPaint, builder.toString()); } private static StringBuilder getStringBuilder() { if (sStringBuilder.get() == null) { sStringBuilder.set(new StringBuilder()); } return sStringBuilder.get(); } } }