| /* |
| * Copyright 2021 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 androidx.emoji2.text; |
| |
| import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; |
| |
| import android.os.Build; |
| 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 androidx.annotation.AnyThread; |
| import androidx.annotation.IntDef; |
| import androidx.annotation.IntRange; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RequiresApi; |
| import androidx.annotation.RestrictTo; |
| import androidx.core.graphics.PaintCompat; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.Arrays; |
| import java.util.List; |
| |
| /** |
| * Processes the CharSequence and adds the emojis. |
| * |
| * @hide |
| */ |
| @AnyThread |
| @RestrictTo(LIBRARY_GROUP_PREFIX) |
| @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. |
| */ |
| @NonNull |
| private final EmojiCompat.SpanFactory mSpanFactory; |
| |
| /** |
| * Emoji metadata repository. |
| */ |
| @NonNull |
| private final MetadataRepo mMetadataRepo; |
| |
| /** |
| * Utility class that checks if the system can render a given glyph. |
| */ |
| @NonNull |
| private EmojiCompat.GlyphChecker mGlyphChecker; |
| |
| /** |
| * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean) |
| */ |
| private final boolean mUseEmojiAsDefaultStyle; |
| |
| /** |
| * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean, List) |
| */ |
| @Nullable |
| private final int[] mEmojiAsDefaultStyleExceptions; |
| |
| EmojiProcessor( |
| @NonNull final MetadataRepo metadataRepo, |
| @NonNull final EmojiCompat.SpanFactory spanFactory, |
| @NonNull final EmojiCompat.GlyphChecker glyphChecker, |
| final boolean useEmojiAsDefaultStyle, |
| @Nullable final int[] emojiAsDefaultStyleExceptions |
| ) { |
| mSpanFactory = spanFactory; |
| mMetadataRepo = metadataRepo; |
| mGlyphChecker = glyphChecker; |
| mUseEmojiAsDefaultStyle = useEmojiAsDefaultStyle; |
| mEmojiAsDefaultStyleExceptions = emojiAsDefaultStyleExceptions; |
| } |
| |
| EmojiMetadata getEmojiMetadata(@NonNull final CharSequence charSequence) { |
| final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode(), |
| mUseEmojiAsDefaultStyle, mEmojiAsDefaultStyleExceptions); |
| 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 first EmojiSpan found in order to change the result |
| // into a Spannable. |
| if (isSpannableBuilder || charSequence instanceof Spannable) { |
| spannable = (Spannable) charSequence; |
| } else if (charSequence instanceof Spanned) { |
| // check if there are any EmojiSpans as cheap as possible |
| // start-1, end+1 will return emoji span that starts/ends at start/end indices |
| final int nextSpanTransition = ((Spanned) charSequence).nextSpanTransition( |
| start - 1, end + 1, EmojiSpan.class); |
| |
| if (nextSpanTransition <= end) { |
| spannable = new SpannableString(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(), |
| mUseEmojiAsDefaultStyle, mEmojiAsDefaultStyleExceptions); |
| |
| 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, |
| @NonNull 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(@NonNull final Editable content, @NonNull 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(@NonNull 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) { |
| // if the existence is not calculated yet |
| if (metadata.getHasGlyph() == EmojiMetadata.HAS_GLYPH_UNKNOWN) { |
| final boolean hasGlyph = mGlyphChecker.hasGlyph(charSequence, start, end, |
| metadata.getSdkAdded()); |
| metadata.setHasGlyph(hasGlyph); |
| } |
| |
| return metadata.getHasGlyph() == EmojiMetadata.HAS_GLYPH_EXISTS; |
| } |
| |
| /** |
| * 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; |
| |
| /** |
| * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean) |
| */ |
| private final boolean mUseEmojiAsDefaultStyle; |
| |
| /** |
| * @see EmojiCompat.Config#setUseEmojiAsDefaultStyle(boolean, List) |
| */ |
| private final int[] mEmojiAsDefaultStyleExceptions; |
| |
| ProcessorSm(MetadataRepo.Node rootNode, boolean useEmojiAsDefaultStyle, |
| int[] emojiAsDefaultStyleExceptions) { |
| mRootNode = rootNode; |
| mCurrentNode = rootNode; |
| mUseEmojiAsDefaultStyle = useEmojiAsDefaultStyle; |
| mEmojiAsDefaultStyleExceptions = emojiAsDefaultStyleExceptions; |
| } |
| |
| @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 (shouldUseEmojiPresentationStyleForSingleCodepoint()) { |
| 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 |
| && (mCurrentDepth > 1 || shouldUseEmojiPresentationStyleForSingleCodepoint()); |
| } |
| |
| private boolean shouldUseEmojiPresentationStyleForSingleCodepoint() { |
| if (mCurrentNode.getData().isDefaultEmoji()) { |
| // The codepoint is emoji style by default. |
| return true; |
| } |
| if (isEmojiStyle(mLastCodepoint)) { |
| // The codepoint was followed by the emoji style variation selector. |
| return true; |
| } |
| if (mUseEmojiAsDefaultStyle) { |
| // Emoji presentation style for text style default emojis is enabled. We have |
| // to check that the current codepoint is not an exception. |
| if (mEmojiAsDefaultStyleExceptions == null) { |
| return true; |
| } |
| final int codepoint = mCurrentNode.getData().getCodepointAt(0); |
| final int index = Arrays.binarySearch(mEmojiAsDefaultStyleExceptions, codepoint); |
| if (index < 0) { |
| // Index is negative, so the codepoint was not found in the array of exceptions. |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * @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. |
| */ |
| @RequiresApi(19) |
| private static final class CodepointIndexFinder { |
| private static final int INVALID_INDEX = -1; |
| |
| private CodepointIndexFinder() {} |
| |
| /** |
| * 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 |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| 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 R 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 |
| */ |
| @SuppressWarnings("WeakerAccess") /* synthetic access */ |
| 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_PREFIX) |
| public static class DefaultGlyphChecker implements EmojiCompat.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; |
| |
| DefaultGlyphChecker() { |
| mTextPaint = new TextPaint(); |
| mTextPaint.setTextSize(PAINT_TEXT_SIZE); |
| } |
| |
| @Override |
| public boolean hasGlyph( |
| @NonNull CharSequence charSequence, |
| int start, |
| int end, |
| int sdkAdded |
| ) { |
| // 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 && sdkAdded > Build.VERSION.SDK_INT) { |
| return false; |
| } |
| |
| 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(); |
| } |
| } |
| } |