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