From 9cdb5a9b6a789687118432ee3a7b13006055647b Mon Sep 17 00:00:00 2001 From: Ali Mahdiyar Date: Thu, 7 May 2020 01:31:02 +0430 Subject: [PATCH] Custom unicode handling for Android backspace via JNI to ICU (#17960) --- ci/licenses_golden/licenses_flutter | 1 + shell/platform/android/BUILD.gn | 1 + .../flutter/embedding/engine/FlutterJNI.java | 14 ++ .../plugin/editing/FlutterTextUtils.java | 189 +++++++++++++++++ .../editing/InputConnectionAdaptor.java | 27 ++- .../android/platform_view_android_jni.cc | 60 ++++++ .../editing/InputConnectionAdaptorTest.java | 190 +++++++++++++++++- tools/android_lint/project.xml | 1 + 8 files changed, 471 insertions(+), 12 deletions(-) mode change 100644 => 100755 ci/licenses_golden/licenses_flutter create mode 100644 shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter old mode 100644 new mode 100755 index f79af121f..7da604868 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -752,6 +752,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/common/PluginReg FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/common/StandardMessageCodec.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/common/StandardMethodCodec.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/common/StringCodec.java +FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/AccessibilityEventsDelegate.java diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 1ed68860f..8f5a6e364 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -201,6 +201,7 @@ android_java_sources = [ "io/flutter/plugin/common/StandardMessageCodec.java", "io/flutter/plugin/common/StandardMethodCodec.java", "io/flutter/plugin/common/StringCodec.java", + "io/flutter/plugin/editing/FlutterTextUtils.java", "io/flutter/plugin/editing/InputConnectionAdaptor.java", "io/flutter/plugin/editing/TextInputPlugin.java", "io/flutter/plugin/platform/AccessibilityEventsDelegate.java", diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java index e599281f8..894ee37dc 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java @@ -146,6 +146,20 @@ public class FlutterJNI { @NonNull public static native FlutterCallbackInformation nativeLookupCallbackInformation(long handle); + // ----- Start FlutterTextUtils Methods ---- + + public native boolean nativeFlutterTextUtilsIsEmoji(int codePoint); + + public native boolean nativeFlutterTextUtilsIsEmojiModifier(int codePoint); + + public native boolean nativeFlutterTextUtilsIsEmojiModifierBase(int codePoint); + + public native boolean nativeFlutterTextUtilsIsVariationSelector(int codePoint); + + public native boolean nativeFlutterTextUtilsIsRegionalIndicator(int codePoint); + + // ----- End Engine FlutterTextUtils Methods ---- + @Nullable private Long nativePlatformViewId; @Nullable private AccessibilityDelegate accessibilityDelegate; @Nullable private PlatformMessageHandler platformMessageHandler; diff --git a/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java b/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java new file mode 100644 index 000000000..19eb1f079 --- /dev/null +++ b/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java @@ -0,0 +1,189 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugin.editing; + +import io.flutter.embedding.engine.FlutterJNI; + +class FlutterTextUtils { + public static final int LINE_FEED = 0x0A; + public static final int CARRIAGE_RETURN = 0x0D; + public static final int COMBINING_ENCLOSING_KEYCAP = 0x20E3; + public static final int CANCEL_TAG = 0xE007F; + public static final int ZERO_WIDTH_JOINER = 0x200D; + private final FlutterJNI flutterJNI; + + public FlutterTextUtils(FlutterJNI flutterJNI) { + this.flutterJNI = flutterJNI; + } + + public boolean isEmoji(int codePoint) { + return flutterJNI.nativeFlutterTextUtilsIsEmoji(codePoint); + } + + public boolean isEmojiModifier(int codePoint) { + return flutterJNI.nativeFlutterTextUtilsIsEmojiModifier(codePoint); + } + + public boolean isEmojiModifierBase(int codePoint) { + return flutterJNI.nativeFlutterTextUtilsIsEmojiModifierBase(codePoint); + } + + public boolean isVariationSelector(int codePoint) { + return flutterJNI.nativeFlutterTextUtilsIsVariationSelector(codePoint); + } + + public boolean isRegionalIndicatorSymbol(int codePoint) { + return flutterJNI.nativeFlutterTextUtilsIsRegionalIndicator(codePoint); + } + + public boolean isTagSpecChar(int codePoint) { + return 0xE0020 <= codePoint && codePoint <= 0xE007E; + } + + public boolean isKeycapBase(int codePoint) { + return ('0' <= codePoint && codePoint <= '9') || codePoint == '#' || codePoint == '*'; + } + + /** + * Start offset for backspace key or moving left from the current offset. Same methods are also + * included in Android APIs but they don't work as expected in API Levels lower than 24. Reference + * for the logic in this code is the Android source code. + * + * @see https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android10-s3-release/core/java/android/text/method/BaseKeyListener.java#111 + */ + public int getOffsetBefore(CharSequence text, int offset) { + if (offset <= 1) { + return 0; + } + + int codePoint = Character.codePointBefore(text, offset); + int deleteCharCount = Character.charCount(codePoint); + int lastOffset = offset - deleteCharCount; + + if (lastOffset == 0) { + return 0; + } + + // Line Feed + if (codePoint == LINE_FEED) { + codePoint = Character.codePointBefore(text, lastOffset); + if (codePoint == CARRIAGE_RETURN) { + ++deleteCharCount; + } + return offset - deleteCharCount; + } + + // Flags + if (isRegionalIndicatorSymbol(codePoint)) { + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + int regionalIndicatorSymbolCount = 1; + while (lastOffset > 0 && isRegionalIndicatorSymbol(codePoint)) { + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + regionalIndicatorSymbolCount++; + } + if (regionalIndicatorSymbolCount % 2 == 0) { + deleteCharCount += 2; + } + return offset - deleteCharCount; + } + + // Keycaps + if (codePoint == COMBINING_ENCLOSING_KEYCAP) { + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + if (lastOffset > 0 && isVariationSelector(codePoint)) { + int tmpCodePoint = Character.codePointBefore(text, lastOffset); + if (isKeycapBase(tmpCodePoint)) { + deleteCharCount += Character.charCount(codePoint) + Character.charCount(tmpCodePoint); + } + } else if (isKeycapBase(codePoint)) { + deleteCharCount += Character.charCount(codePoint); + } + return offset - deleteCharCount; + } + + /** + * Following if statements for Emoji tag sequence and Variation selector are skipping these + * modifiers for going through the last statement that is for handling emojis. They return the + * offset if they don't find proper base characters + */ + // Emoji Tag Sequence + if (codePoint == CANCEL_TAG) { // tag_end + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + while (lastOffset > 0 && isTagSpecChar(codePoint)) { // tag_spec + deleteCharCount += Character.charCount(codePoint); + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + } + if (!isEmoji(codePoint)) { // tag_base not found. Just delete the end. + return offset - 2; + } + deleteCharCount += Character.charCount(codePoint); + } + + if (isVariationSelector(codePoint)) { + codePoint = Character.codePointBefore(text, lastOffset); + if (!isEmoji(codePoint)) { + return offset - deleteCharCount; + } + deleteCharCount += Character.charCount(codePoint); + + lastOffset -= deleteCharCount; + } + + if (isEmoji(codePoint)) { + boolean isZwj = false; + int lastSeenVariantSelectorCharCount = 0; + do { + if (isZwj) { + deleteCharCount += Character.charCount(codePoint) + lastSeenVariantSelectorCharCount + 1; + isZwj = false; + } + lastSeenVariantSelectorCharCount = 0; + if (isEmojiModifier(codePoint)) { + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + if (lastOffset > 0 && isVariationSelector(codePoint)) { + codePoint = Character.codePointBefore(text, lastOffset); + if (!isEmoji(codePoint)) { + return offset - deleteCharCount; + } + lastSeenVariantSelectorCharCount = Character.charCount(codePoint); + lastOffset -= Character.charCount(codePoint); + } + if (isEmojiModifierBase(codePoint)) { + deleteCharCount += lastSeenVariantSelectorCharCount + Character.charCount(codePoint); + } + break; + } + + if (lastOffset > 0) { + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + if (codePoint == ZERO_WIDTH_JOINER) { + isZwj = true; + codePoint = Character.codePointBefore(text, lastOffset); + lastOffset -= Character.charCount(codePoint); + if (lastOffset > 0 && isVariationSelector(codePoint)) { + codePoint = Character.codePointBefore(text, lastOffset); + lastSeenVariantSelectorCharCount = Character.charCount(codePoint); + lastOffset -= Character.charCount(codePoint); + } + } + } + + if (lastOffset == 0) { + break; + } + } while (isZwj && isEmoji(codePoint)); + } + + return offset - deleteCharCount; + } +} diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index a990ff386..b3eea58ae 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -16,7 +16,6 @@ import android.text.InputType; import android.text.Layout; import android.text.Selection; import android.text.TextPaint; -import android.text.method.TextKeyListener; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.BaseInputConnection; @@ -27,6 +26,7 @@ import android.view.inputmethod.ExtractedTextRequest; import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodSubtype; import io.flutter.Log; +import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.systemchannels.TextInputChannel; class InputConnectionAdaptor extends BaseInputConnection { @@ -38,6 +38,7 @@ class InputConnectionAdaptor extends BaseInputConnection { private int mBatchCount; private InputMethodManager mImm; private final Layout mLayout; + private FlutterTextUtils flutterTextUtils; // Used to determine if Samsung-specific hacks should be applied. private final boolean isSamsung; @@ -96,7 +97,8 @@ class InputConnectionAdaptor extends BaseInputConnection { int client, TextInputChannel textInputChannel, Editable editable, - EditorInfo editorInfo) { + EditorInfo editorInfo, + FlutterJNI flutterJNI) { super(view, true); mFlutterView = view; mClient = client; @@ -104,6 +106,7 @@ class InputConnectionAdaptor extends BaseInputConnection { mEditable = editable; mEditorInfo = editorInfo; mBatchCount = 0; + this.flutterTextUtils = new FlutterTextUtils(flutterJNI); // We create a dummy Layout with max width so that the selection // shifting acts as if all text were in one line. mLayout = @@ -120,6 +123,15 @@ class InputConnectionAdaptor extends BaseInputConnection { isSamsung = isSamsung(); } + public InputConnectionAdaptor( + View view, + int client, + TextInputChannel textInputChannel, + Editable editable, + EditorInfo editorInfo) { + this(view, client, textInputChannel, editable, editorInfo, new FlutterJNI()); + } + // Send the current state of the editable to Flutter. private void updateEditingState() { // If the IME is in the middle of a batch edit, then wait until it completes. @@ -315,19 +327,18 @@ class InputConnectionAdaptor extends BaseInputConnection { if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { int selStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable); int selEnd = clampIndexToEditable(Selection.getSelectionEnd(mEditable), mEditable); + if (selStart == selEnd && selStart > 0) { + // Extend selection to left of the last character + selStart = flutterTextUtils.getOffsetBefore(mEditable, selStart); + } if (selEnd > selStart) { // Delete the selection. Selection.setSelection(mEditable, selStart); mEditable.delete(selStart, selEnd); updateEditingState(); return true; - } else if (selStart > 0) { - if (TextKeyListener.getInstance().onKeyDown(null, mEditable, event.getKeyCode(), event)) { - updateEditingState(); - return true; - } - return false; } + return false; } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) { int selStart = Selection.getSelectionStart(mEditable); int selEnd = Selection.getSelectionEnd(mEditable); diff --git a/shell/platform/android/platform_view_android_jni.cc b/shell/platform/android/platform_view_android_jni.cc index 8b70d145d..4f6f9258b 100644 --- a/shell/platform/android/platform_view_android_jni.cc +++ b/shell/platform/android/platform_view_android_jni.cc @@ -7,6 +7,7 @@ #include #include +#include "unicode/uchar.h" #include "flutter/assets/directory_asset_bundle.h" #include "flutter/common/settings.h" @@ -484,6 +485,35 @@ static void InvokePlatformMessageEmptyResponseCallback(JNIEnv* env, ); } +static jboolean FlutterTextUtilsIsEmoji(JNIEnv* env, + jobject obj, + jint codePoint) { + return u_hasBinaryProperty(codePoint, UProperty::UCHAR_EMOJI); +} + +static jboolean FlutterTextUtilsIsEmojiModifier(JNIEnv* env, + jobject obj, + jint codePoint) { + return u_hasBinaryProperty(codePoint, UProperty::UCHAR_EMOJI_MODIFIER); +} + +static jboolean FlutterTextUtilsIsEmojiModifierBase(JNIEnv* env, + jobject obj, + jint codePoint) { + return u_hasBinaryProperty(codePoint, UProperty::UCHAR_EMOJI_MODIFIER_BASE); +} + +static jboolean FlutterTextUtilsIsVariationSelector(JNIEnv* env, + jobject obj, + jint codePoint) { + return u_hasBinaryProperty(codePoint, UProperty::UCHAR_VARIATION_SELECTOR); +} + +static jboolean FlutterTextUtilsIsRegionalIndicator(JNIEnv* env, + jobject obj, + jint codePoint) { + return u_hasBinaryProperty(codePoint, UProperty::UCHAR_REGIONAL_INDICATOR); +} bool RegisterApi(JNIEnv* env) { static const JNINativeMethod flutter_jni_methods[] = { // Start of methods from FlutterJNI @@ -599,6 +629,36 @@ bool RegisterApi(JNIEnv* env) { .signature = "(J)Lio/flutter/view/FlutterCallbackInformation;", .fnPtr = reinterpret_cast(&LookupCallbackInformation), }, + + // Start of methods for FlutterTextUtils + { + .name = "nativeFlutterTextUtilsIsEmoji", + .signature = "(I)Z", + .fnPtr = reinterpret_cast(&FlutterTextUtilsIsEmoji), + }, + { + .name = "nativeFlutterTextUtilsIsEmojiModifier", + .signature = "(I)Z", + .fnPtr = reinterpret_cast(&FlutterTextUtilsIsEmojiModifier), + }, + { + .name = "nativeFlutterTextUtilsIsEmojiModifierBase", + .signature = "(I)Z", + .fnPtr = + reinterpret_cast(&FlutterTextUtilsIsEmojiModifierBase), + }, + { + .name = "nativeFlutterTextUtilsIsVariationSelector", + .signature = "(I)Z", + .fnPtr = + reinterpret_cast(&FlutterTextUtilsIsVariationSelector), + }, + { + .name = "nativeFlutterTextUtilsIsRegionalIndicator", + .signature = "(I)Z", + .fnPtr = + reinterpret_cast(&FlutterTextUtilsIsRegionalIndicator), + }, }; if (env->RegisterNatives(g_flutter_jni_class->obj(), flutter_jni_methods, 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 bbca2464a..37ae918c4 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -3,16 +3,19 @@ package io.flutter.plugin.editing; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.anyInt; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import android.content.ClipboardManager; import android.content.res.AssetManager; import android.text.Editable; +import android.text.Emoji; import android.text.InputType; import android.text.Selection; import android.text.SpannableStringBuilder; @@ -316,7 +319,7 @@ public class InputConnectionAdaptorTest { @Test public void testSendKeyEvent_delKeyDeletesBackward() { int selStart = 29; - Editable editable = sampleRtlEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart, SAMPLE_RTL_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); @@ -334,9 +337,171 @@ public class InputConnectionAdaptorTest { assertEquals(Selection.getSelectionStart(editable), 10); } + @Test + public void testSendKeyEvent_delKeyDeletesBackwardComplexEmojis() { + int selStart = 75; + Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); + 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); + } + private static final String SAMPLE_TEXT = "Lorem ipsum dolor sit amet," + "\nconsectetur adipiscing elit."; + private static final String SAMPLE_EMOJI_TEXT = + "a" // First CodePoint + + "😂" // Simple Emoji + + "🇮🇷" // Regional Indicator Symbol even + + "🇷" // Regional Indicator Symbol odd + + "\r\n" // Carriage Return and Line Feed + + "\r\n" + + "✋🏿" // Emoji Modifier + + "✋🏿" + + "⚠️" // Variant Selector + + "⚠️" + + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" // Emoji Tag Sequence + + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + + "a‍👨" // Zero Width Joiner + + "👨‍👩‍👧‍👦" + + "5️⃣" // Keycap + + "5️⃣" + + "عَ" // Non-Spacing Mark + + "a"; // Normal Character + private static final String SAMPLE_RTL_TEXT = "متن ساختگی" + "\nبرای تستfor test😊"; private static Editable sampleEditable(int selStart, int selEnd) { @@ -345,8 +510,8 @@ public class InputConnectionAdaptorTest { return sample; } - private static Editable sampleRtlEditable(int selStart, int selEnd) { - SpannableStringBuilder sample = new SpannableStringBuilder(SAMPLE_RTL_TEXT); + private static Editable sampleEditable(int selStart, int selEnd, String text) { + SpannableStringBuilder sample = new SpannableStringBuilder(text); Selection.setSelection(sample, selStart, selEnd); return sample; } @@ -355,7 +520,24 @@ public class InputConnectionAdaptorTest { View testView = new View(RuntimeEnvironment.application); int client = 0; TextInputChannel textInputChannel = mock(TextInputChannel.class); - return new InputConnectionAdaptor(testView, client, textInputChannel, editable, null); + FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); + when(mockFlutterJNI.nativeFlutterTextUtilsIsEmoji(anyInt())) + .thenAnswer((invocation) -> Emoji.isEmoji((int) invocation.getArguments()[0])); + when(mockFlutterJNI.nativeFlutterTextUtilsIsEmojiModifier(anyInt())) + .thenAnswer((invocation) -> Emoji.isEmojiModifier((int) invocation.getArguments()[0])); + when(mockFlutterJNI.nativeFlutterTextUtilsIsEmojiModifierBase(anyInt())) + .thenAnswer((invocation) -> Emoji.isEmojiModifierBase((int) invocation.getArguments()[0])); + when(mockFlutterJNI.nativeFlutterTextUtilsIsVariationSelector(anyInt())) + .thenAnswer( + (invocation) -> { + int codePoint = (int) invocation.getArguments()[0]; + return 0xFE0E <= codePoint && codePoint <= 0xFE0F; + }); + when(mockFlutterJNI.nativeFlutterTextUtilsIsRegionalIndicator(anyInt())) + .thenAnswer( + (invocation) -> Emoji.isRegionalIndicatorSymbol((int) invocation.getArguments()[0])); + return new InputConnectionAdaptor( + testView, client, textInputChannel, editable, null, mockFlutterJNI); } private class TestTextInputChannel extends TextInputChannel { diff --git a/tools/android_lint/project.xml b/tools/android_lint/project.xml index e8174354f..f557fb58e 100644 --- a/tools/android_lint/project.xml +++ b/tools/android_lint/project.xml @@ -65,6 +65,7 @@ + -- GitLab