diff --git a/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java b/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java index 19eb1f0790a03..fd1e430841427 100644 --- a/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java +++ b/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java @@ -186,4 +186,134 @@ public int getOffsetBefore(CharSequence text, int offset) { return offset - deleteCharCount; } + + /** + * Gets the offset of the next character following the given offset, with consideration for + * multi-byte characters. + * + * @see https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android10-s3-release/core/java/android/text/method/BaseKeyListener.java#111 + */ + public int getOffsetAfter(CharSequence text, int offset) { + final int len = text.length(); + + if (offset >= len - 1) { + return len; + } + + int codePoint = Character.codePointAt(text, offset); + int nextCharCount = Character.charCount(codePoint); + int nextOffset = offset + nextCharCount; + + if (nextOffset == 0) { + return 0; + } + + // Line Feed + if (codePoint == LINE_FEED) { + codePoint = Character.codePointAt(text, nextOffset); + if (codePoint == CARRIAGE_RETURN) { + ++nextCharCount; + } + return offset + nextCharCount; + } + + // Flags + if (isRegionalIndicatorSymbol(codePoint)) { + if (nextOffset >= len - 1 + || !isRegionalIndicatorSymbol(Character.codePointAt(text, nextOffset))) { + return offset + nextCharCount; + } + // In this case there are at least two regional indicator symbols ahead of + // offset. If those two regional indicator symbols are a pair that + // represent a region together, the next offset should be after both of + // them. + int regionalIndicatorSymbolCount = 0; + int regionOffset = offset; + while (regionOffset > 0 + && isRegionalIndicatorSymbol(Character.codePointBefore(text, offset))) { + regionOffset -= Character.charCount(Character.codePointBefore(text, offset)); + regionalIndicatorSymbolCount++; + } + if (regionalIndicatorSymbolCount % 2 == 0) { + nextCharCount += 2; + } + return offset + nextCharCount; + } + + // Keycaps + if (isKeycapBase(codePoint)) { + nextCharCount += Character.charCount(codePoint); + } + if (codePoint == COMBINING_ENCLOSING_KEYCAP) { + codePoint = Character.codePointBefore(text, nextOffset); + nextOffset += Character.charCount(codePoint); + if (nextOffset < len && isVariationSelector(codePoint)) { + int tmpCodePoint = Character.codePointAt(text, nextOffset); + if (isKeycapBase(tmpCodePoint)) { + nextCharCount += Character.charCount(codePoint) + Character.charCount(tmpCodePoint); + } + } else if (isKeycapBase(codePoint)) { + nextCharCount += Character.charCount(codePoint); + } + return offset + nextCharCount; + } + + if (isEmoji(codePoint)) { + boolean isZwj = false; + int lastSeenVariantSelectorCharCount = 0; + do { + if (isZwj) { + nextCharCount += Character.charCount(codePoint) + lastSeenVariantSelectorCharCount + 1; + isZwj = false; + } + lastSeenVariantSelectorCharCount = 0; + if (isEmojiModifier(codePoint)) { + break; + } + + if (nextOffset < len) { + codePoint = Character.codePointAt(text, nextOffset); + nextOffset += Character.charCount(codePoint); + if (codePoint == COMBINING_ENCLOSING_KEYCAP) { + codePoint = Character.codePointBefore(text, nextOffset); + nextOffset += Character.charCount(codePoint); + if (nextOffset < len && isVariationSelector(codePoint)) { + int tmpCodePoint = Character.codePointAt(text, nextOffset); + if (isKeycapBase(tmpCodePoint)) { + nextCharCount += Character.charCount(codePoint) + Character.charCount(tmpCodePoint); + } + } else if (isKeycapBase(codePoint)) { + nextCharCount += Character.charCount(codePoint); + } + return offset + nextCharCount; + } + if (isEmojiModifier(codePoint)) { + nextCharCount += lastSeenVariantSelectorCharCount + Character.charCount(codePoint); + break; + } + if (isVariationSelector(codePoint)) { + nextCharCount += lastSeenVariantSelectorCharCount + Character.charCount(codePoint); + break; + } + if (codePoint == ZERO_WIDTH_JOINER) { + isZwj = true; + codePoint = Character.codePointAt(text, nextOffset); + nextOffset += Character.charCount(codePoint); + if (nextOffset < len && isVariationSelector(codePoint)) { + codePoint = Character.codePointAt(text, nextOffset); + lastSeenVariantSelectorCharCount = Character.charCount(codePoint); + nextOffset += Character.charCount(codePoint); + } + } + } + + if (nextOffset >= len) { + break; + } + } while (isZwj && isEmoji(codePoint)); + } + + return offset + nextCharCount; + } } diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index b3eea58aed1ff..29b3fb859e1ef 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -343,10 +343,10 @@ public boolean sendKeyEvent(KeyEvent event) { int selStart = Selection.getSelectionStart(mEditable); int selEnd = Selection.getSelectionEnd(mEditable); if (selStart == selEnd && !event.isShiftPressed()) { - int newSel = Math.max(selStart - 1, 0); + int newSel = Math.max(flutterTextUtils.getOffsetBefore(mEditable, selStart), 0); setSelection(newSel, newSel); } else { - int newSelEnd = Math.max(selEnd - 1, 0); + int newSelEnd = Math.max(flutterTextUtils.getOffsetBefore(mEditable, selEnd), 0); setSelection(selStart, newSelEnd); } return true; @@ -354,10 +354,12 @@ public boolean sendKeyEvent(KeyEvent event) { int selStart = Selection.getSelectionStart(mEditable); int selEnd = Selection.getSelectionEnd(mEditable); if (selStart == selEnd && !event.isShiftPressed()) { - int newSel = Math.min(selStart + 1, mEditable.length()); + int newSel = + Math.min(flutterTextUtils.getOffsetAfter(mEditable, selStart), mEditable.length()); setSelection(newSel, newSel); } else { - int newSelEnd = Math.min(selEnd + 1, mEditable.length()); + int newSelEnd = + Math.min(flutterTextUtils.getOffsetAfter(mEditable, selEnd), mEditable.length()); setSelection(selStart, newSelEnd); } return true; diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index bdf2d6b48c5d6..d3afb22e5976d 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -154,6 +154,148 @@ public void testSendKeyEvent_leftKeyMovesCaretLeft() { assertEquals(selStart - 1, Selection.getSelectionEnd(editable)); } + @Test + public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { + int selStart = 75; + Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); + boolean didConsume; + + // Normal Character + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 74); + + // Non-Spacing Mark + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 73); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 72); + + // Keycap + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 69); + + // Keycap with invalid base + adaptor.setSelection(68, 68); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 66); + adaptor.setSelection(67, 67); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 66); + + // Zero Width Joiner + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 55); + + // Zero Width Joiner with invalid base + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 53); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 52); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 51); + + // ----- Start Emoji Tag Sequence with invalid base testing ---- + // Delete base tag + adaptor.setSelection(39, 39); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 37); + + // Delete the sequence + adaptor.setSelection(49, 49); + for (int i = 0; i < 6; i++) { + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + } + assertEquals(Selection.getSelectionStart(editable), 37); + // ----- End Emoji Tag Sequence with invalid base testing ---- + + // Emoji Tag Sequence + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 23); + + // Variation Selector with invalid base + adaptor.setSelection(22, 22); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 21); + adaptor.setSelection(22, 22); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 21); + + // Variation Selector + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 19); + + // Emoji Modifier + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 16); + + // Emoji Modifier with invalid base + adaptor.setSelection(14, 14); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 13); + adaptor.setSelection(14, 14); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 13); + + // Line Feed + adaptor.setSelection(12, 12); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 11); + + // Carriage Return + adaptor.setSelection(12, 12); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 11); + + // Carriage Return and Line Feed + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 9); + + // Regional Indicator Symbol odd + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 7); + + // Regional Indicator Symbol even + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 3); + + // Simple Emoji + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 1); + + // First CodePoint + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 0); + } + @Test public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { int selStart = 5; @@ -199,6 +341,179 @@ public void testSendKeyEvent_rightKeyMovesCaretRight() { assertEquals(selStart + 1, Selection.getSelectionEnd(editable)); } + @Test + public void testSendKeyEvent_rightKeyMovesCaretRightComplexRegion() { + int selStart = 0; + // Seven region indicator characters. The first six should be considered as + // three region indicators, and the final seventh character should be + // considered to be on its own because it has no partner. + String SAMPLE_REGION_TEXT = "🇷🇷🇷🇷🇷🇷🇷"; + Editable editable = sampleEditable(selStart, selStart, SAMPLE_REGION_TEXT); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); + boolean didConsume; + + // The cursor moves over two region indicators at a time. + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 4); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 8); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 12); + + // When there is only one region indicator left with no pair, the cursor + // moves over that single region indicator. + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 14); + + // If the cursor is placed in the middle of a region indicator pair, it + // moves over only the second half of the pair. + adaptor.setSelection(6, 6); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 8); + } + + @Test + public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { + int selStart = 0; + Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); + boolean didConsume; + + // First CodePoint + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 1); + + // Simple Emoji + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 3); + + // Regional Indicator Symbol even + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 7); + + // Regional Indicator Symbol odd + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 9); + + // Carriage Return + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 10); + + // Line Feed and Carriage Return + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 12); + + // Line Feed + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 13); + + // Modified Emoji + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 16); + + // Emoji Modifier + adaptor.setSelection(14, 14); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 16); + + // Emoji Modifier with invalid base + adaptor.setSelection(18, 18); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 19); + + // Variation Selector + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 21); + + // Variation Selector with invalid base + adaptor.setSelection(22, 22); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 23); + + // Emoji Tag Sequence + for (int i = 0; i < 7; i++) { + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 25 + 2 * i); + } + assertEquals(Selection.getSelectionStart(editable), 37); + + // ----- Start Emoji Tag Sequence with invalid base testing ---- + // Pass the sequence + adaptor.setSelection(39, 39); + for (int i = 0; i < 6; i++) { + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 41 + 2 * i); + } + assertEquals(Selection.getSelectionStart(editable), 51); + // ----- End Emoji Tag Sequence with invalid base testing ---- + + // Zero Width Joiner with invalid base + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 52); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 53); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 55); + + // Zero Width Joiner + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 66); + + // Keycap with invalid base + adaptor.setSelection(67, 67); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 68); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 69); + + // Keycap + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 72); + + // Non-Spacing Mark + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 73); + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 74); + + // Normal Character + didConsume = adaptor.sendKeyEvent(downKeyDown); + assertTrue(didConsume); + assertEquals(Selection.getSelectionStart(editable), 75); + } + @Test public void testSendKeyEvent_rightKeyExtendsSelectionRight() { int selStart = 5;