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 @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 99 EmojiProcessor(@NonNull final MetadataRepo metadataRepo, 100 @NonNull final EmojiCompat.SpanFactory spanFactory) { 101 mSpanFactory = spanFactory; 102 mMetadataRepo = metadataRepo; 103 } 104 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 */ 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 */ 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 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 */ 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 403 private static boolean hasInvalidSelection(final int start, final int end) { 404 return start == -1 || end == -1 || start != end; 405 } 406 407 private static boolean hasModifiers(KeyEvent event) { 408 return !KeyEvent.metaStateHasNoModifiers(event.getMetaState()); 409 } 410 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 */ 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 */ 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 491 ProcessorSm(MetadataRepo.Node rootNode) { 492 mRootNode = rootNode; 493 mCurrentNode = rootNode; 494 } 495 496 @Action 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 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 */ 559 EmojiMetadata getFlushMetadata() { 560 return mFlushNode.getData(); 561 } 562 563 /** 564 * @return current pointer to the metadata node in the trie 565 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 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 */ 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 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