blob: 3feb36d6fb957e654f05e723f9a8ae046418dd2d [file] [log] [blame]
/*
* 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)
private @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;
/**
* 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.
* <p>
* <ul>
* <li>If no emojis are found, {@code charSequence} given as the input is returned without
* any changes. i.e. charSequence is a String, and no emojis are found, the same String is
* returned.</li>
* <li>If the given input is not a Spannable (such as String), and at least one emoji is found
* a new {@link android.text.Spannable} instance is returned. </li>
* <li>If the given input is a Spannable, the same instance is returned. </li>
* </ul>
*
* @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 != EmojiCompat.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:
start += Character.charCount(Character.codePointAt(charSequence, start));
currentOffset = start;
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.
* <p/>
* 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.
* <p/>
* 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) {
//noinspection ConstantConditions
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<StringBuilder> 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();
}
}
}