Custom unicode handling for Android backspace via JNI to ICU (#17960)

diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter
old mode 100644
new mode 100755
index f79af12..7da6048
--- 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/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 1ed6886..8f5a6e3 100644
--- a/shell/platform/android/BUILD.gn
+++ b/shell/platform/android/BUILD.gn
@@ -201,6 +201,7 @@
   "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 e599281..894ee37 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 @@
   @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 0000000..19eb1f0
--- /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 <a target="_new"
+   *     href="https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android10-s3-release/core/java/android/text/method/BaseKeyListener.java#111">https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android10-s3-release/core/java/android/text/method/BaseKeyListener.java#111</a>
+   */
+  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 a990ff3..b3eea58 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.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.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 @@
   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 @@
       int client,
       TextInputChannel textInputChannel,
       Editable editable,
-      EditorInfo editorInfo) {
+      EditorInfo editorInfo,
+      FlutterJNI flutterJNI) {
     super(view, true);
     mFlutterView = view;
     mClient = client;
@@ -104,6 +106,7 @@
     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 @@
     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 @@
       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 8b70d14..4f6f925 100644
--- a/shell/platform/android/platform_view_android_jni.cc
+++ b/shell/platform/android/platform_view_android_jni.cc
@@ -7,6 +7,7 @@
 #include <android/native_window_jni.h>
 
 #include <utility>
+#include "unicode/uchar.h"
 
 #include "flutter/assets/directory_asset_bundle.h"
 #include "flutter/common/settings.h"
@@ -484,6 +485,35 @@
       );
 }
 
+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 @@
           .signature = "(J)Lio/flutter/view/FlutterCallbackInformation;",
           .fnPtr = reinterpret_cast<void*>(&LookupCallbackInformation),
       },
+
+      // Start of methods for FlutterTextUtils
+      {
+          .name = "nativeFlutterTextUtilsIsEmoji",
+          .signature = "(I)Z",
+          .fnPtr = reinterpret_cast<void*>(&FlutterTextUtilsIsEmoji),
+      },
+      {
+          .name = "nativeFlutterTextUtilsIsEmojiModifier",
+          .signature = "(I)Z",
+          .fnPtr = reinterpret_cast<void*>(&FlutterTextUtilsIsEmojiModifier),
+      },
+      {
+          .name = "nativeFlutterTextUtilsIsEmojiModifierBase",
+          .signature = "(I)Z",
+          .fnPtr =
+              reinterpret_cast<void*>(&FlutterTextUtilsIsEmojiModifierBase),
+      },
+      {
+          .name = "nativeFlutterTextUtilsIsVariationSelector",
+          .signature = "(I)Z",
+          .fnPtr =
+              reinterpret_cast<void*>(&FlutterTextUtilsIsVariationSelector),
+      },
+      {
+          .name = "nativeFlutterTextUtilsIsRegionalIndicator",
+          .signature = "(I)Z",
+          .fnPtr =
+              reinterpret_cast<void*>(&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 bbca246..37ae918 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 @@
 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 @@
   @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 @@
     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 @@
     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 @@
     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 e817435..f557fb5 100644
--- a/tools/android_lint/project.xml
+++ b/tools/android_lint/project.xml
@@ -65,6 +65,7 @@
     <src file="../../../flutter/shell/platform/android/io/flutter/plugin/common/MethodCall.java" />
     <src file="../../../flutter/shell/platform/android/io/flutter/plugin/common/EventChannel.java" />
     <src file="../../../flutter/shell/platform/android/io/flutter/plugin/common/MethodChannel.java" />
+    <src file="../../../flutter/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java" />
     <src file="../../../flutter/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java" />
     <src file="../../../flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java" />
     <src file="../../../flutter/shell/platform/android/io/flutter/view/FlutterNativeView.java" />