diff --git a/fml/platform/android/jni_util.cc b/fml/platform/android/jni_util.cc index b84a351f1c913..24903dad3b6d3 100644 --- a/fml/platform/android/jni_util.cc +++ b/fml/platform/android/jni_util.cc @@ -165,5 +165,21 @@ std::string GetJavaExceptionInfo(JNIEnv* env, jthrowable java_throwable) { return JavaStringToString(env, exception_string.obj()); } +void ThrowException(JNIEnv* env, const char* class_name, const char* message) { + jclass clazz = env->FindClass(class_name); + FML_DCHECK(clazz); + env->ThrowNew(clazz, message); +} + +ScopedJavaStringChars::ScopedJavaStringChars(JNIEnv* env, jstring str) + : env_(env), str_(str) { + chars_ = env_->GetStringChars(str_, nullptr); + FML_DCHECK(chars_); +} + +ScopedJavaStringChars::~ScopedJavaStringChars() { + env_->ReleaseStringChars(str_, chars_); +} + } // namespace jni } // namespace fml diff --git a/fml/platform/android/jni_util.h b/fml/platform/android/jni_util.h index 9fe32242c503c..cc50f4c742b5d 100644 --- a/fml/platform/android/jni_util.h +++ b/fml/platform/android/jni_util.h @@ -38,6 +38,21 @@ bool ClearException(JNIEnv* env); std::string GetJavaExceptionInfo(JNIEnv* env, jthrowable java_throwable); +void ThrowException(JNIEnv* env, const char* class_name, const char* message); + +class ScopedJavaStringChars { + public: + ScopedJavaStringChars(JNIEnv* env, jstring str); + ~ScopedJavaStringChars(); + + const jchar* chars() { return chars_; } + + private: + JNIEnv* env_; + jstring str_; + const jchar* chars_; +}; + } // namespace jni } // namespace fml diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java index e599281f84137..ca1f64b4e1995 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java @@ -146,6 +146,15 @@ public static native void nativeOnVsync( @NonNull public static native FlutterCallbackInformation nativeLookupCallbackInformation(long handle); + /** + * Return the index of the first grapheme cluster preceding {@code offset} in {@code text}. + * + *

This is similar to {@link android.text.TextUtils#getOffsetBefore(CharSequence, int, int)} + * but it uses the engine's ICU library which is up to date and acts consistently on all versions + * of Android. + */ + public static native int nativeGetTextOffsetBefore(String text, int offset); + @Nullable private Long nativePlatformViewId; @Nullable private AccessibilityDelegate accessibilityDelegate; @Nullable private PlatformMessageHandler platformMessageHandler; diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 8a50cde7b714b..fa921677771fe 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -13,7 +13,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; @@ -21,7 +20,9 @@ import android.view.inputmethod.ExtractedText; import android.view.inputmethod.ExtractedTextRequest; import android.view.inputmethod.InputMethodManager; +import androidx.annotation.VisibleForTesting; import io.flutter.Log; +import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.systemchannels.TextInputChannel; class InputConnectionAdaptor extends BaseInputConnection { @@ -111,6 +112,23 @@ public InputConnectionAdaptor( mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); } + interface GetOffsetBefore { + int getOffsetBefore(String text, int offset); + } + + private GetOffsetBefore getOffsetBefore = + new GetOffsetBefore() { + @Override + public int getOffsetBefore(String text, int offset) { + return FlutterJNI.nativeGetTextOffsetBefore(text, offset); + } + }; + + @VisibleForTesting + void setGetOffsetBefore(GetOffsetBefore value) { + getOffsetBefore = value; + } + // 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. @@ -270,18 +288,16 @@ public boolean sendKeyEvent(KeyEvent event) { 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 the left of the last character. + selStart = getOffsetBefore.getOffsetBefore(mEditable.toString(), 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; } } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) { int selStart = Selection.getSelectionStart(mEditable); diff --git a/shell/platform/android/platform_view_android_jni.cc b/shell/platform/android/platform_view_android_jni.cc index 8b70d145dc439..45c3806f21dac 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/brkiter.h" #include "flutter/assets/directory_asset_bundle.h" #include "flutter/common/settings.h" @@ -484,6 +485,28 @@ static void InvokePlatformMessageEmptyResponseCallback(JNIEnv* env, ); } +// Return the index of the first grapheme cluster that precedes the given +// offset in the text. +static jint GetTextOffsetBefore(JNIEnv* env, + jclass jcaller, + jstring text, + jint offset) { + std::unique_ptr breaker; + UErrorCode status = U_ZERO_ERROR; + breaker.reset( + icu::BreakIterator::createCharacterInstance(icu::Locale(), status)); + if (!U_SUCCESS(status)) { + fml::jni::ThrowException(env, "java/lang/IllegalStateException", + "unable to create character iterator"); + return -1; + } + + fml::jni::ScopedJavaStringChars chars(env, text); + breaker->setText( + icu::UnicodeString(false, chars.chars(), env->GetStringLength(text))); + return breaker->preceding(offset); +} + bool RegisterApi(JNIEnv* env) { static const JNINativeMethod flutter_jni_methods[] = { // Start of methods from FlutterJNI @@ -599,6 +622,12 @@ bool RegisterApi(JNIEnv* env) { .signature = "(J)Lio/flutter/view/FlutterCallbackInformation;", .fnPtr = reinterpret_cast(&LookupCallbackInformation), }, + + { + .name = "nativeGetTextOffsetBefore", + .signature = "(Ljava/lang/String;I)I", + .fnPtr = reinterpret_cast(&GetTextOffsetBefore), + }, }; 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 bbca2464a6232..2a2dfc7869d7e 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -16,6 +16,7 @@ import android.text.InputType; import android.text.Selection; import android.text.SpannableStringBuilder; +import android.text.TextUtils; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.EditorInfo; @@ -318,6 +319,13 @@ public void testSendKeyEvent_delKeyDeletesBackward() { int selStart = 29; Editable editable = sampleRtlEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + adaptor.setGetOffsetBefore( + new InputConnectionAdaptor.GetOffsetBefore() { + @Override + public int getOffsetBefore(String text, int offset) { + return TextUtils.getOffsetBefore(text, offset); + } + }); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);