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