diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index 0415dca1126b..5b7847e6e836 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -37,9 +37,11 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityNodeInfo +import android.view.autofill.AutofillValue import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager +import androidx.annotation.RequiresApi import androidx.appcompat.widget.AppCompatEditText import androidx.core.graphics.withTranslation import androidx.core.util.Predicate @@ -147,6 +149,12 @@ public open class ReactEditText public constructor(context: Context) : AppCompat internal var disableTextDiffing: Boolean = false protected var isSettingTextFromState: Boolean = false + // TextView.autofill() sets the new text (notifying the TextWatchers) before it moves the + // cursor to the end of the text, so watchers reading the selection while an autofill is in + // progress would see a stale position. + internal var isBeingAutofilled: Boolean = false + private set + private var eventDispatcher: EventDispatcher? = null private var textWatcherDelegator: TextWatcherDelegator? = null @@ -339,6 +347,16 @@ public open class ReactEditText public constructor(context: Context) : AppCompat // We don't call super.setLineHeight() because LineHeight is fully managed by ReactNative } + @RequiresApi(Build.VERSION_CODES.O) + override fun autofill(value: AutofillValue) { + isBeingAutofilled = true + try { + super.autofill(value) + } finally { + isBeingAutofilled = false + } + } + override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) { super.onScrollChanged(horiz, vert, oldHoriz, oldVert) scrollWatcher?.onScrollChanged(horiz, vert, oldHoriz, oldVert) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt index 22e20f799c21..076bb1d43813 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputTextWatcher.kt @@ -18,8 +18,9 @@ import com.facebook.react.uimanager.events.EventDispatcher internal class ReactTextInputTextWatcher( reactContext: ReactContext, private val editText: ReactEditText, + private val eventDispatcher: EventDispatcher? = + UIManagerHelper.getEventDispatcher(reactContext), ) : TextWatcher { - private val eventDispatcher: EventDispatcher? = UIManagerHelper.getEventDispatcher(reactContext) private val surfaceId = UIManagerHelper.getSurfaceId(reactContext) private var previousText: String? = null @@ -55,6 +56,11 @@ internal class ReactTextInputTextWatcher( stateWrapper.updateState(newStateData) } + // While an autofill is in progress the cursor still sits at its pre-autofill position; + // TextView.autofill() moves it to the end of the text only after this watcher has run. + val selectionStart = if (editText.isBeingAutofilled) s.length else editText.selectionStart + val selectionEnd = if (editText.isBeingAutofilled) s.length else editText.selectionEnd + // The event that contains the event counter and updates it must be sent first. eventDispatcher?.dispatchEvent( ReactTextChangedEvent( @@ -62,8 +68,8 @@ internal class ReactTextInputTextWatcher( editText.id, s.toString(), editText.incrementAndGetEventCounter(), - editText.selectionStart, - editText.selectionEnd, + selectionStart, + selectionEnd, ) ) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputTextWatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputTextWatcherTest.kt new file mode 100644 index 000000000000..deb9672f0d18 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputTextWatcherTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// TODO T207169925: Migrate CatalystInstance to Reacthost and remove the Suppress("DEPRECATION") +// annotation +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.textinput + +import android.util.DisplayMetrics +import android.view.autofill.AutofillValue +import androidx.core.content.res.ResourcesCompat.ID_NULL +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.CatalystInstance +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.bridge.WritableMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.testutils.fakes.FakeEventDispatcher +import com.facebook.testutils.shadows.ShadowArguments +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Verify the selection reported in {@link ReactTextChangedEvent}s emitted by {@link + * ReactTextInputTextWatcher} + */ +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowArguments::class]) +class ReactTextInputTextWatcherTest { + + private lateinit var context: BridgeReactContext + private lateinit var catalystInstanceMock: CatalystInstance + private lateinit var themedContext: ThemedReactContext + private lateinit var manager: ReactTextInputManager + private lateinit var view: ReactEditText + private lateinit var eventDispatcher: FakeEventDispatcher + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + context = BridgeReactContext(RuntimeEnvironment.getApplication()) + catalystInstanceMock = createMockCatalystInstance() + context.initializeWithInstance(catalystInstanceMock) + themedContext = ThemedReactContext(context, context.baseContext, null, ID_NULL) + manager = ReactTextInputManager() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + view = manager.createViewInstance(themedContext) + eventDispatcher = FakeEventDispatcher() + view.addTextChangedListener(ReactTextInputTextWatcher(themedContext, view, eventDispatcher)) + } + + @Test + fun testAutofillReportsSelectionAtEndOfText() { + val autofilledText = "+15551234567" + + view.autofill(AutofillValue.forText(autofilledText)) + + // The framework moves the cursor to the end of the text after autofilling + assertThat(view.selectionStart).isEqualTo(autofilledText.length) + assertThat(view.selectionEnd).isEqualTo(autofilledText.length) + + val eventData = lastTextChangedEventData() + assertThat(eventData.getString("text")).isEqualTo(autofilledText) + val selection = checkNotNull(eventData.getMap("selection")) + assertThat(selection.getInt("start")).isEqualTo(autofilledText.length) + assertThat(selection.getInt("end")).isEqualTo(autofilledText.length) + } + + @Test + fun testTextInsertedAtCursorReportsSelectionAfterInsertedText() { + view.setText("+1555123") + view.setSelection(8) + + // Mimics pasting/typing "4567" at the cursor position + checkNotNull(view.text).insert(8, "4567") + + val eventData = lastTextChangedEventData() + assertThat(eventData.getString("text")).isEqualTo("+15551234567") + val selection = checkNotNull(eventData.getMap("selection")) + assertThat(selection.getInt("start")).isEqualTo(12) + assertThat(selection.getInt("end")).isEqualTo(12) + } + + private fun lastTextChangedEventData(): WritableMap { + val events = + eventDispatcher.getRecordedDispatchedEvents().filterIsInstance() + assertThat(events).isNotEmpty + return checkNotNull(events.last().internal_getEventData()) + } +}