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 5858f865273f..205d5ccdb5bc 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 @@ -32,6 +32,8 @@ import android.text.TextUtils import android.text.TextWatcher import android.text.method.KeyListener import android.text.method.QwertyKeyListener +import android.text.style.CharacterStyle +import android.text.style.ParagraphStyle import android.util.TypedValue import android.view.ActionMode import android.view.DragEvent @@ -1242,6 +1244,10 @@ public open class ReactEditText public constructor(context: Context) : AppCompat } catch (e: IndexOutOfBoundsException) { logSoftException(TAG, e) } + // Drop editor spans like PasswordTransformationMethod.Visible, which hold on to this view + stripSpansOfKind(sb, Any::class.java) { span -> + span !is CharacterStyle && span !is ParagraphStyle && span !is ReactSpan + } } // If we don't have text, make sure we have *something* to measure. diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextCachedSpannableTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextCachedSpannableTest.kt new file mode 100644 index 000000000000..f4a93e116f79 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextCachedSpannableTest.kt @@ -0,0 +1,79 @@ +/* + * 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. + */ + +package com.facebook.react.views.textinput + +import android.provider.Settings +import android.text.Spannable +import android.text.method.PasswordTransformationMethod +import android.util.DisplayMetrics +import androidx.core.content.res.ResourcesCompat.ID_NULL +import com.facebook.react.bridge.ReactTestHelper.createTestReactApplicationContext +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.text.TextLayoutManager +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class]) +class ReactEditTextCachedSpannableTest { + + private lateinit var view: ReactEditText + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + val context = createTestReactApplicationContext(RuntimeEnvironment.getApplication()) + val themedContext = ThemedReactContext(context, context.baseContext, null, ID_NULL) + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + view = ReactTextInputManager().createViewInstance(themedContext) + view.id = 42 + view.stateWrapper = mock() + } + + @Test + fun testCachedSpannableDoesNotKeepPasswordSpans() { + Settings.System.putInt( + RuntimeEnvironment.getApplication().contentResolver, + Settings.System.TEXT_SHOW_PASSWORD, + 1, + ) + view.transformationMethod = PasswordTransformationMethod.getInstance() + + view.text?.append("a") + view.text?.append("b") + + assertThat(spanOwners(checkNotNull(view.text))) + .contains(PasswordTransformationMethod::class.java) + assertThat(spanOwners(getCachedSpannable(view.id))) + .doesNotContain(PasswordTransformationMethod::class.java) + } + + private fun spanOwners(text: Spannable): List?> = + text.getSpans(0, text.length, Any::class.java).map { it.javaClass.enclosingClass } + + @Suppress("UNCHECKED_CAST") + private fun getCachedSpannable(tag: Int): Spannable { + val cache = + TextLayoutManager::class + .java + .getDeclaredField("tagToSpannableCache") + .apply { isAccessible = true } + .get(null) as Map + return checkNotNull(cache[tag]) + } +}