1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package android.support.text.emoji;
17
18import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
19
20import android.os.Build;
21import android.support.annotation.AnyThread;
22import android.support.annotation.IntDef;
23import android.support.annotation.IntRange;
24import android.support.annotation.NonNull;
25import android.support.annotation.RequiresApi;
26import android.support.annotation.RestrictTo;
27import android.support.text.emoji.widget.SpannableBuilder;
28import android.support.v4.graphics.PaintCompat;
29import android.support.v4.util.Preconditions;
30import android.text.Editable;
31import android.text.Selection;
32import android.text.Spannable;
33import android.text.SpannableString;
34import android.text.Spanned;
35import android.text.TextPaint;
36import android.text.method.KeyListener;
37import android.text.method.MetaKeyKeyListener;
38import android.view.KeyEvent;
39import android.view.inputmethod.InputConnection;
40
41import java.lang.annotation.Retention;
42import java.lang.annotation.RetentionPolicy;
43
44/**
45 * Processes the CharSequence and adds the emojis.
46 *
47 * @hide
48 */
49@AnyThread
50@RestrictTo(LIBRARY_GROUP)
51@RequiresApi(19)
52final class EmojiProcessor {
53
54    /**
55     * State transition commands.
56     */
57    @IntDef({ACTION_ADVANCE_BOTH, ACTION_ADVANCE_END, ACTION_FLUSH})
58    @Retention(RetentionPolicy.SOURCE)
59    private @interface Action {
60    }
61
62    /**
63     * Advance the end pointer in CharSequence and reset the start to be the end.
64     */
65    private static final int ACTION_ADVANCE_BOTH = 1;
66
67    /**
68     * Advance end pointer in CharSequence.
69     */
70    private static final int ACTION_ADVANCE_END = 2;
71
72    /**
73     * Add a new emoji with the metadata in {@link ProcessorSm#getFlushMetadata()}. Advance end
74     * pointer in CharSequence and reset the start to be the end.
75     */
76    private static final int ACTION_FLUSH = 3;
77
78    /**
79     * Factory used to create EmojiSpans.
80     */
81    private final EmojiCompat.SpanFactory mSpanFactory;
82
83    /**
84     * Emoji metadata repository.
85     */
86    private final MetadataRepo mMetadataRepo;
87
88    /**
89     * Utility class that checks if the system can render a given glyph.
90     */
91    private GlyphChecker mGlyphChecker = new GlyphChecker();
92
93    EmojiProcessor(@NonNull final MetadataRepo metadataRepo,
94            @NonNull final EmojiCompat.SpanFactory spanFactory) {
95        mSpanFactory = spanFactory;
96        mMetadataRepo = metadataRepo;
97    }
98
99    EmojiMetadata getEmojiMetadata(@NonNull final CharSequence charSequence) {
100        final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode());
101        final int end = charSequence.length();
102        int currentOffset = 0;
103
104        while (currentOffset < end) {
105            final int codePoint = Character.codePointAt(charSequence, currentOffset);
106            final int action = sm.check(codePoint);
107            if (action != ACTION_ADVANCE_END) {
108                return null;
109            }
110            currentOffset += Character.charCount(codePoint);
111        }
112
113        if (sm.isInFlushableState()) {
114            return sm.getCurrentMetadata();
115        }
116
117        return null;
118    }
119
120    /**
121     * Checks a given CharSequence for emojis, and adds EmojiSpans if any emojis are found.
122     * <p>
123     * <ul>
124     * <li>If no emojis are found, {@code charSequence} given as the input is returned without
125     * any changes. i.e. charSequence is a String, and no emojis are found, the same String is
126     * returned.</li>
127     * <li>If the given input is not a Spannable (such as String), and at least one emoji is found
128     * a new {@link android.text.Spannable} instance is returned. </li>
129     * <li>If the given input is a Spannable, the same instance is returned. </li>
130     * </ul>
131     *
132     * @param charSequence CharSequence to add the EmojiSpans, cannot be {@code null}
133     * @param start start index in the charSequence to look for emojis, should be greater than or
134     *              equal to {@code 0}, also less than {@code charSequence.length()}
135     * @param end end index in the charSequence to look for emojis, should be greater than or
136     *            equal to {@code start} parameter, also less than {@code charSequence.length()}
137     * @param maxEmojiCount maximum number of emojis in the {@code charSequence}, should be greater
138     *                      than or equal to {@code 0}
139     * @param replaceAll whether to replace all emoji with {@link EmojiSpan}s
140     */
141    CharSequence process(@NonNull final CharSequence charSequence, @IntRange(from = 0) int start,
142            @IntRange(from = 0) int end, @IntRange(from = 0) int maxEmojiCount,
143            final boolean replaceAll) {
144        final boolean isSpannableBuilder = charSequence instanceof SpannableBuilder;
145        if (isSpannableBuilder) {
146            ((SpannableBuilder) charSequence).beginBatchEdit();
147        }
148
149        try {
150            Spannable spannable = null;
151            // if it is a spannable already, use the same instance to add/remove EmojiSpans.
152            // otherwise wait until the the first EmojiSpan found in order to change the result
153            // into a Spannable.
154            if (isSpannableBuilder || charSequence instanceof Spannable) {
155                spannable = (Spannable) charSequence;
156            }
157
158            if (spannable != null) {
159                final EmojiSpan[] spans = spannable.getSpans(start, end, EmojiSpan.class);
160                if (spans != null && spans.length > 0) {
161                    // remove existing spans, and realign the start, end according to spans
162                    // if start or end is in the middle of an emoji they should be aligned
163                    final int length = spans.length;
164                    for (int index = 0; index < length; index++) {
165                        final EmojiSpan span = spans[index];
166                        final int spanStart = spannable.getSpanStart(span);
167                        final int spanEnd = spannable.getSpanEnd(span);
168                        // Remove span only when its spanStart is NOT equal to current end.
169                        // During add operation an emoji at index 0 is added with 0-1 as start and
170                        // end indices. Therefore if there are emoji spans at [0-1] and [1-2]
171                        // and end is 1, the span between 0-1 should be deleted, not 1-2.
172                        if (spanStart != end) {
173                            spannable.removeSpan(span);
174                        }
175                        start = Math.min(spanStart, start);
176                        end = Math.max(spanEnd, end);
177                    }
178                }
179            }
180
181            if (start == end || start >= charSequence.length()) {
182                return charSequence;
183            }
184
185            // calculate max number of emojis that can be added. since getSpans call is a relatively
186            // expensive operation, do it only when maxEmojiCount is not unlimited.
187            if (maxEmojiCount != EmojiCompat.EMOJI_COUNT_UNLIMITED && spannable != null) {
188                maxEmojiCount -= spannable.getSpans(0, spannable.length(), EmojiSpan.class).length;
189            }
190            // add new ones
191            int addedCount = 0;
192            final ProcessorSm sm = new ProcessorSm(mMetadataRepo.getRootNode());
193
194            int currentOffset = start;
195            int codePoint = Character.codePointAt(charSequence, currentOffset);
196
197            while (currentOffset < end && addedCount < maxEmojiCount) {
198                final int action = sm.check(codePoint);
199
200                switch (action) {
201                    case ACTION_ADVANCE_BOTH:
202                        start += Character.charCount(Character.codePointAt(charSequence, start));
203                        currentOffset = start;
204                        if (currentOffset < end) {
205                            codePoint = Character.codePointAt(charSequence, currentOffset);
206                        }
207                        break;
208                    case ACTION_ADVANCE_END:
209                        currentOffset += Character.charCount(codePoint);
210                        if (currentOffset < end) {
211                            codePoint = Character.codePointAt(charSequence, currentOffset);
212                        }
213                        break;
214                    case ACTION_FLUSH:
215                        if (replaceAll || !hasGlyph(charSequence, start, currentOffset,
216                                sm.getFlushMetadata())) {
217                            if (spannable == null) {
218                                spannable = new SpannableString(charSequence);
219                            }
220                            addEmoji(spannable, sm.getFlushMetadata(), start, currentOffset);
221                            addedCount++;
222                        }
223                        start = currentOffset;
224                        break;
225                }
226            }
227
228            // After the last codepoint is consumed the state machine might be in a state where it
229            // identified an emoji before. i.e. abc[women-emoji] when the last codepoint is consumed
230            // state machine is waiting to see if there is an emoji sequence (i.e. ZWJ).
231            // Need to check if it is in such a state.
232            if (sm.isInFlushableState() && addedCount < maxEmojiCount) {
233                if (replaceAll || !hasGlyph(charSequence, start, currentOffset,
234                        sm.getCurrentMetadata())) {
235                    if (spannable == null) {
236                        spannable = new SpannableString(charSequence);
237                    }
238                    addEmoji(spannable, sm.getCurrentMetadata(), start, currentOffset);
239                    addedCount++;
240                }
241            }
242            return spannable == null ? charSequence : spannable;
243        } finally {
244            if (isSpannableBuilder) {
245                ((SpannableBuilder) charSequence).endBatchEdit();
246            }
247        }
248    }
249
250    /**
251     * Handles onKeyDown commands from a {@link KeyListener} and if {@code keyCode} is one of
252     * {@link KeyEvent#KEYCODE_DEL} or {@link KeyEvent#KEYCODE_FORWARD_DEL} it tries to delete an
253     * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is
254     * deleted with the characters it covers.
255     * <p/>
256     * If there is a selection where selection start is not equal to selection end, does not
257     * delete.
258     *
259     * @param editable Editable instance passed to {@link KeyListener#onKeyDown(android.view.View,
260     *                 Editable, int, KeyEvent)}
261     * @param keyCode keyCode passed to {@link KeyListener#onKeyDown(android.view.View, Editable,
262     *                int, KeyEvent)}
263     * @param event KeyEvent passed to {@link KeyListener#onKeyDown(android.view.View, Editable,
264     *              int, KeyEvent)}
265     *
266     * @return {@code true} if an {@link EmojiSpan} is deleted
267     */
268    static boolean handleOnKeyDown(@NonNull final Editable editable, final int keyCode,
269            final KeyEvent event) {
270        final boolean handled;
271        switch (keyCode) {
272            case KeyEvent.KEYCODE_DEL:
273                handled = delete(editable, event, false /*forwardDelete*/);
274                break;
275            case KeyEvent.KEYCODE_FORWARD_DEL:
276                handled = delete(editable, event, true /*forwardDelete*/);
277                break;
278            default:
279                handled = false;
280                break;
281        }
282
283        if (handled) {
284            MetaKeyKeyListener.adjustMetaAfterKeypress(editable);
285            return true;
286        }
287
288        return false;
289    }
290
291    private static boolean delete(final Editable content, final KeyEvent event,
292            final boolean forwardDelete) {
293        if (hasModifiers(event)) {
294            return false;
295        }
296
297        final int start = Selection.getSelectionStart(content);
298        final int end = Selection.getSelectionEnd(content);
299        if (hasInvalidSelection(start, end)) {
300            return false;
301        }
302
303        final EmojiSpan[] spans = content.getSpans(start, end, EmojiSpan.class);
304        if (spans != null && spans.length > 0) {
305            final int length = spans.length;
306            for (int index = 0; index < length; index++) {
307                final EmojiSpan span = spans[index];
308                final int spanStart = content.getSpanStart(span);
309                final int spanEnd = content.getSpanEnd(span);
310                if ((forwardDelete && spanStart == start)
311                        || (!forwardDelete && spanEnd == start)
312                        || (start > spanStart && start < spanEnd)) {
313                    content.delete(spanStart, spanEnd);
314                    return true;
315                }
316            }
317        }
318
319        return false;
320    }
321
322    /**
323     * Handles deleteSurroundingText commands from {@link InputConnection} and tries to delete an
324     * {@link EmojiSpan} from an {@link Editable}. Returns {@code true} if an {@link EmojiSpan} is
325     * deleted.
326     * <p/>
327     * If there is a selection where selection start is not equal to selection end, does not
328     * delete.
329     *
330     * @param inputConnection InputConnection instance
331     * @param editable TextView.Editable instance
332     * @param beforeLength the number of characters before the cursor to be deleted
333     * @param afterLength the number of characters after the cursor to be deleted
334     * @param inCodePoints {@code true} if length parameters are in codepoints
335     *
336     * @return {@code true} if an {@link EmojiSpan} is deleted
337     */
338    static boolean handleDeleteSurroundingText(@NonNull final InputConnection inputConnection,
339            @NonNull final Editable editable, @IntRange(from = 0) final int beforeLength,
340            @IntRange(from = 0) final int afterLength, final boolean inCodePoints) {
341        //noinspection ConstantConditions
342        if (editable == null || inputConnection == null) {
343            return false;
344        }
345
346        if (beforeLength < 0 || afterLength < 0) {
347            return false;
348        }
349
350        final int selectionStart = Selection.getSelectionStart(editable);
351        final int selectionEnd = Selection.getSelectionEnd(editable);
352
353        if (hasInvalidSelection(selectionStart, selectionEnd)) {
354            return false;
355        }
356
357        int start;
358        int end;
359        if (inCodePoints) {
360            // go backwards in terms of codepoints
361            start = CodepointIndexFinder.findIndexBackward(editable, selectionStart,
362                    Math.max(beforeLength, 0));
363            end = CodepointIndexFinder.findIndexForward(editable, selectionEnd,
364                    Math.max(afterLength, 0));
365
366            if (start == CodepointIndexFinder.INVALID_INDEX
367                    || end == CodepointIndexFinder.INVALID_INDEX) {
368                return false;
369            }
370        } else {
371            start = Math.max(selectionStart - beforeLength, 0);
372            end = Math.min(selectionEnd + afterLength, editable.length());
373        }
374
375        final EmojiSpan[] spans = editable.getSpans(start, end, EmojiSpan.class);
376        if (spans != null && spans.length > 0) {
377            final int length = spans.length;
378            for (int index = 0; index < length; index++) {
379                final EmojiSpan span = spans[index];
380                int spanStart = editable.getSpanStart(span);
381                int spanEnd = editable.getSpanEnd(span);
382                start = Math.min(spanStart, start);
383                end = Math.max(spanEnd, end);
384            }
385
386            start = Math.max(start, 0);
387            end = Math.min(end, editable.length());
388
389            inputConnection.beginBatchEdit();
390            editable.delete(start, end);
391            inputConnection.endBatchEdit();
392            return true;
393        }
394
395        return false;
396    }
397
398    private static boolean hasInvalidSelection(final int start, final int end) {
399        return start == -1 || end == -1 || start != end;
400    }
401
402    private static boolean hasModifiers(KeyEvent event) {
403        return !KeyEvent.metaStateHasNoModifiers(event.getMetaState());
404    }
405
406    private void addEmoji(@NonNull final Spannable spannable, final EmojiMetadata metadata,
407            final int start, final int end) {
408        final EmojiSpan span = mSpanFactory.createSpan(metadata);
409        spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
410    }
411
412    /**
413     * Checks whether the current OS can render a given emoji. Used by the system to decide if an
414     * emoji span should be added. If the system cannot render it, an emoji span will be added.
415     * Used only for the case where replaceAll is set to {@code false}.
416     *
417     * @param charSequence the CharSequence that the emoji is in
418     * @param start start index of the emoji in the CharSequence
419     * @param end end index of the emoji in the CharSequence
420     * @param metadata EmojiMetadata instance for the emoji
421     *
422     * @return {@code true} if the OS can render emoji, {@code false} otherwise
423     */
424    private boolean hasGlyph(final CharSequence charSequence, int start, final int end,
425            final EmojiMetadata metadata) {
426        // For pre M devices, heuristic in PaintCompat can result in false positives. we are
427        // adding another heuristic using the sdkAdded field. if the emoji was added to OS
428        // at a later version we assume that the system probably cannot render it.
429        if (Build.VERSION.SDK_INT < 23 && metadata.getSdkAdded() > Build.VERSION.SDK_INT) {
430            return false;
431        }
432
433        // if the existence is not calculated yet
434        if (metadata.getHasGlyph() == EmojiMetadata.HAS_GLYPH_UNKNOWN) {
435            final boolean hasGlyph = mGlyphChecker.hasGlyph(charSequence, start, end);
436            metadata.setHasGlyph(hasGlyph);
437        }
438
439        return metadata.getHasGlyph() == EmojiMetadata.HAS_GLYPH_EXISTS;
440    }
441
442    /**
443     * Set the GlyphChecker instance used by EmojiProcessor. Used for testing.
444     */
445    void setGlyphChecker(@NonNull final GlyphChecker glyphChecker) {
446        Preconditions.checkNotNull(glyphChecker);
447        mGlyphChecker = glyphChecker;
448    }
449
450    /**
451     * State machine for walking over the metadata trie.
452     */
453    static final class ProcessorSm {
454
455        private static final int STATE_DEFAULT = 1;
456        private static final int STATE_WALKING = 2;
457
458        private int mState = STATE_DEFAULT;
459
460        /**
461         * Root of the trie
462         */
463        private final MetadataRepo.Node mRootNode;
464
465        /**
466         * Pointer to the node after last codepoint.
467         */
468        private MetadataRepo.Node mCurrentNode;
469
470        /**
471         * The node where ACTION_FLUSH is called. Required since after flush action is
472         * returned mCurrentNode is reset to be the root.
473         */
474        private MetadataRepo.Node mFlushNode;
475
476        /**
477         * The code point that was checked.
478         */
479        private int mLastCodepoint;
480
481        /**
482         * Level for mCurrentNode. Root is 0.
483         */
484        private int mCurrentDepth;
485
486        ProcessorSm(MetadataRepo.Node rootNode) {
487            mRootNode = rootNode;
488            mCurrentNode = rootNode;
489        }
490
491        @Action
492        int check(final int codePoint) {
493            final int action;
494            MetadataRepo.Node node = mCurrentNode.get(codePoint);
495            switch (mState) {
496                case STATE_WALKING:
497                    if (node != null) {
498                        mCurrentNode = node;
499                        mCurrentDepth += 1;
500                        action = ACTION_ADVANCE_END;
501                    } else {
502                        if (isTextStyle(codePoint)) {
503                            action = reset();
504                        } else if (isEmojiStyle(codePoint)) {
505                            action = ACTION_ADVANCE_END;
506                        } else if (mCurrentNode.getData() != null) {
507                            if (mCurrentDepth == 1) {
508                                if (mCurrentNode.getData().isDefaultEmoji()
509                                        || isEmojiStyle(mLastCodepoint)) {
510                                    mFlushNode = mCurrentNode;
511                                    action = ACTION_FLUSH;
512                                    reset();
513                                } else {
514                                    action = reset();
515                                }
516                            } else {
517                                mFlushNode = mCurrentNode;
518                                action = ACTION_FLUSH;
519                                reset();
520                            }
521                        } else {
522                            action = reset();
523                        }
524                    }
525                    break;
526                case STATE_DEFAULT:
527                default:
528                    if (node == null) {
529                        action = reset();
530                    } else {
531                        mState = STATE_WALKING;
532                        mCurrentNode = node;
533                        mCurrentDepth = 1;
534                        action = ACTION_ADVANCE_END;
535                    }
536                    break;
537            }
538
539            mLastCodepoint = codePoint;
540            return action;
541        }
542
543        @Action
544        private int reset() {
545            mState = STATE_DEFAULT;
546            mCurrentNode = mRootNode;
547            mCurrentDepth = 0;
548            return ACTION_ADVANCE_BOTH;
549        }
550
551        /**
552         * @return the metadata node when ACTION_FLUSH is returned
553         */
554        EmojiMetadata getFlushMetadata() {
555            return mFlushNode.getData();
556        }
557
558        /**
559         * @return current pointer to the metadata node in the trie
560         */
561        EmojiMetadata getCurrentMetadata() {
562            return mCurrentNode.getData();
563        }
564
565        /**
566         * Need for the case where input is consumed, but action_flush was not called. For example
567         * when the char sequence has single codepoint character which is a default emoji. State
568         * machine will wait for the next.
569         *
570         * @return whether the current state requires an emoji to be added
571         */
572        boolean isInFlushableState() {
573            return mState == STATE_WALKING && mCurrentNode.getData() != null
574                    && (mCurrentNode.getData().isDefaultEmoji()
575                    || isEmojiStyle(mLastCodepoint)
576                    || mCurrentDepth > 1);
577        }
578
579        /**
580         * @param codePoint CodePoint to check
581         *
582         * @return {@code true} if the codepoint is a emoji style standardized variation selector
583         */
584        private static boolean isEmojiStyle(int codePoint) {
585            return codePoint == 0xFE0F;
586        }
587
588        /**
589         * @param codePoint CodePoint to check
590         *
591         * @return {@code true} if the codepoint is a text style standardized variation selector
592         */
593        private static boolean isTextStyle(int codePoint) {
594            return codePoint == 0xFE0E;
595        }
596    }
597
598    /**
599     * Copy of BaseInputConnection findIndexBackward and findIndexForward functions.
600     */
601    private static final class CodepointIndexFinder {
602        private static final int INVALID_INDEX = -1;
603
604        /**
605         * Find start index of the character in {@code cs} that is {@code numCodePoints} behind
606         * starting from {@code from}.
607         *
608         * @param cs CharSequence to work on
609         * @param from the index to start going backwards
610         * @param numCodePoints the number of codepoints
611         *
612         * @return start index of the character
613         */
614        private static int findIndexBackward(final CharSequence cs, final int from,
615                final int numCodePoints) {
616            int currentIndex = from;
617            boolean waitingHighSurrogate = false;
618            final int length = cs.length();
619            if (currentIndex < 0 || length < currentIndex) {
620                return INVALID_INDEX;  // The starting point is out of range.
621            }
622            if (numCodePoints < 0) {
623                return INVALID_INDEX;  // Basically this should not happen.
624            }
625            int remainingCodePoints = numCodePoints;
626            while (true) {
627                if (remainingCodePoints == 0) {
628                    return currentIndex;  // Reached to the requested length in code points.
629                }
630
631                --currentIndex;
632                if (currentIndex < 0) {
633                    if (waitingHighSurrogate) {
634                        return INVALID_INDEX;  // An invalid surrogate pair is found.
635                    }
636                    return 0;  // Reached to the beginning of the text w/o any invalid surrogate
637                    // pair.
638                }
639                final char c = cs.charAt(currentIndex);
640                if (waitingHighSurrogate) {
641                    if (!Character.isHighSurrogate(c)) {
642                        return INVALID_INDEX;  // An invalid surrogate pair is found.
643                    }
644                    waitingHighSurrogate = false;
645                    --remainingCodePoints;
646                    continue;
647                }
648                if (!Character.isSurrogate(c)) {
649                    --remainingCodePoints;
650                    continue;
651                }
652                if (Character.isHighSurrogate(c)) {
653                    return INVALID_INDEX;  // A invalid surrogate pair is found.
654                }
655                waitingHighSurrogate = true;
656            }
657        }
658
659        /**
660         * Find start index of the character in {@code cs} that is {@code numCodePoints} ahead
661         * starting from {@code from}.
662         *
663         * @param cs CharSequence to work on
664         * @param from the index to start going forward
665         * @param numCodePoints the number of codepoints
666         *
667         * @return start index of the character
668         */
669        private static int findIndexForward(final CharSequence cs, final int from,
670                final int numCodePoints) {
671            int currentIndex = from;
672            boolean waitingLowSurrogate = false;
673            final int length = cs.length();
674            if (currentIndex < 0 || length < currentIndex) {
675                return INVALID_INDEX;  // The starting point is out of range.
676            }
677            if (numCodePoints < 0) {
678                return INVALID_INDEX;  // Basically this should not happen.
679            }
680            int remainingCodePoints = numCodePoints;
681
682            while (true) {
683                if (remainingCodePoints == 0) {
684                    return currentIndex;  // Reached to the requested length in code points.
685                }
686
687                if (currentIndex >= length) {
688                    if (waitingLowSurrogate) {
689                        return INVALID_INDEX;  // An invalid surrogate pair is found.
690                    }
691                    return length;  // Reached to the end of the text w/o any invalid surrogate
692                    // pair.
693                }
694                final char c = cs.charAt(currentIndex);
695                if (waitingLowSurrogate) {
696                    if (!Character.isLowSurrogate(c)) {
697                        return INVALID_INDEX;  // An invalid surrogate pair is found.
698                    }
699                    --remainingCodePoints;
700                    waitingLowSurrogate = false;
701                    ++currentIndex;
702                    continue;
703                }
704                if (!Character.isSurrogate(c)) {
705                    --remainingCodePoints;
706                    ++currentIndex;
707                    continue;
708                }
709                if (Character.isLowSurrogate(c)) {
710                    return INVALID_INDEX;  // A invalid surrogate pair is found.
711                }
712                waitingLowSurrogate = true;
713                ++currentIndex;
714            }
715        }
716    }
717
718    /**
719     * Utility class that checks if the system can render a given glyph.
720     *
721     * @hide
722     */
723    @AnyThread
724    @RestrictTo(LIBRARY_GROUP)
725    public static class GlyphChecker {
726        /**
727         * Default text size for {@link #mTextPaint}.
728         */
729        private static final int PAINT_TEXT_SIZE = 10;
730
731        /**
732         * Used to create strings required by
733         * {@link PaintCompat#hasGlyph(android.graphics.Paint, String)}.
734         */
735        private static final ThreadLocal<StringBuilder> sStringBuilder = new ThreadLocal<>();
736
737        /**
738         * TextPaint used during {@link PaintCompat#hasGlyph(android.graphics.Paint, String)} check.
739         */
740        private final TextPaint mTextPaint;
741
742        GlyphChecker() {
743            mTextPaint = new TextPaint();
744            mTextPaint.setTextSize(PAINT_TEXT_SIZE);
745        }
746
747        /**
748         * Returns whether the system can render an emoji.
749         *
750         * @param charSequence the CharSequence that the emoji is in
751         * @param start start index of the emoji in the CharSequence
752         * @param end end index of the emoji in the CharSequence
753         *
754         * @return {@code true} if the OS can render emoji, {@code false} otherwise
755         */
756        public boolean hasGlyph(final CharSequence charSequence, int start, final int end) {
757            final StringBuilder builder = getStringBuilder();
758            builder.setLength(0);
759
760            while (start < end) {
761                builder.append(charSequence.charAt(start));
762                start++;
763            }
764
765            return PaintCompat.hasGlyph(mTextPaint, builder.toString());
766        }
767
768        private static StringBuilder getStringBuilder() {
769            if (sStringBuilder.get() == null) {
770                sStringBuilder.set(new StringBuilder());
771            }
772            return sStringBuilder.get();
773        }
774
775    }
776}
777