From b81be9bb3102a1e874d9be285c77b484ab2552b5 Mon Sep 17 00:00:00 2001 From: Jason Simmons Date: Wed, 29 Apr 2020 14:15:59 -0700 Subject: [PATCH] Handle backspace on Android by using the engine's ICU grapheme breaker The backspace key handler in the Android text input plugin needs to identify the logical character preceding the cursor and remove it from the editable. Older versions of Android may not provide APIs that can do this with accurate handling of current emojis. This implementation handles it by invoking the icu::BreakIterator via JNI. --- fml/platform/android/jni_util.cc | 16 ++++++++++ fml/platform/android/jni_util.h | 15 ++++++++++ .../flutter/embedding/engine/FlutterJNI.java | 9 ++++++ .../editing/InputConnectionAdaptor.java | 30 ++++++++++++++----- .../android/platform_view_android_jni.cc | 29 ++++++++++++++++++ .../editing/InputConnectionAdaptorTest.java | 8 +++++ 6 files changed, 100 insertions(+), 7 deletions(-) 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);