From 37c74f08b83c41ff5bc48ef48e3f7d26c840b091 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 12 Oct 2026 21:47:02 +0300 Subject: [PATCH] Compose Android keyboardType and secureTextEntry into one input type keyboardType and secureTextEntry both write the class and variation of the staged input type, each with bit edits applied in the order the props arrive. A variation is a value within its class, not a set of flags: 0x10 is TYPE_TEXT_VARIATION_URI in the text class and TYPE_NUMBER_VARIATION_PASSWORD in the number class. Depending on that order: - secureTextEntry={false} clears the url and visible-password variations. - email-address with secureTextEntry commits TYPE_TEXT_VARIATION_WEB_EDIT_TEXT, which is not masked. - secureTextEntry set after autoCapitalize="words" or "characters" commits text + URI, which is not masked, because checkPasswordType reads the capitalization flags as the number flags they share bits with. - Changing keyboardType keeps the previous variation (url then email-address commits EMAIL_SUBJECT) and the previous number flags. - phone-pad with secureTextEntry commits a variation the phone class does not define. Both setters now only stage their value, and onAfterUpdateTransaction resolves the class and variation once, before reconcileAutoCapitalize reads the class, the way autoCapitalize is already deferred. A secure field takes the password variation of its class, and a class without one keeps its keyboard. --- .../react/views/textinput/ReactEditText.kt | 2 + .../views/textinput/ReactTextInputManager.kt | 60 ++++---- .../textinput/ReactTextInputPropertyTest.kt | 132 ++++++++++++++++++ 3 files changed, 166 insertions(+), 28 deletions(-) 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 754f10026624..e58ebcaa994a 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 @@ -123,6 +123,8 @@ public open class ReactEditText public constructor(context: Context) : AppCompat public var stagedInputType: Int internal var stagedAutoCapitalize: Int = 0 + internal var stagedKeyboardInputType: Int = InputType.TYPE_CLASS_TEXT + internal var stagedSecureTextEntry: Boolean = false public var submitBehavior: String? = null public var dragAndDropFilter: List? = null diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt index 82e5abd361bd..2e839b234ad7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt @@ -696,17 +696,8 @@ public open class ReactTextInputManager public constructor() : @ReactProp(name = "secureTextEntry", defaultBoolean = false) public fun setSecureTextEntry(view: ReactEditText, password: Boolean) { - updateStagedInputTypeFlag( - view = view, - flagsToUnset = - if (password) { - InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD - } else { - InputType.TYPE_NUMBER_VARIATION_PASSWORD or InputType.TYPE_TEXT_VARIATION_PASSWORD - }, - flagsToSet = if (password) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0, - ) - checkPasswordType(view) + // Deferred to onAfterUpdateTransaction(), where it is applied to the keyboard type. + view.stagedSecureTextEntry = password } // This prop temporarily takes both numbers and strings. @@ -756,15 +747,13 @@ public open class ReactTextInputManager public constructor() : } else if (KEYBOARD_TYPE_PHONE_PAD.equals(keyboardType, ignoreCase = true)) { flagsToSet = InputType.TYPE_CLASS_PHONE } else if (KEYBOARD_TYPE_VISIBLE_PASSWORD.equals(keyboardType, ignoreCase = true)) { - // This will supersede secureTextEntry={false}. If it doesn't, due to the way - // the flags work out, the underlying field will end up a URI-type field. flagsToSet = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD } else if (KEYBOARD_TYPE_URI.equals(keyboardType, ignoreCase = true)) { flagsToSet = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI } - updateStagedInputTypeFlag(view, InputType.TYPE_MASK_CLASS, flagsToSet) - checkPasswordType(view) + // Deferred to onAfterUpdateTransaction(), where secureTextEntry is applied to it. + view.stagedKeyboardInputType = flagsToSet } @ReactProp(name = "returnKeyType") @@ -905,6 +894,7 @@ public open class ReactTextInputManager public constructor() : override fun onAfterUpdateTransaction(view: ReactEditText) { super.onAfterUpdateTransaction(view) + reconcileKeyboardInputType(view) reconcileAutoCapitalize(view) view.commitStagedInputType() // setInputType() clears paint-level font variation settings, so update the typeface last to @@ -1148,6 +1138,11 @@ public open class ReactTextInputManager public constructor() : (InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or InputType.TYPE_TEXT_FLAG_CAP_WORDS or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS) + private const val KEYBOARD_INPUT_TYPE_MASK = + (InputType.TYPE_MASK_CLASS or + InputType.TYPE_MASK_VARIATION or + InputType.TYPE_NUMBER_FLAG_SIGNED or + InputType.TYPE_NUMBER_FLAG_DECIMAL) private const val KEYBOARD_TYPE_EMAIL_ADDRESS = "email-address" private const val KEYBOARD_TYPE_NUMERIC = "numeric" @@ -1192,21 +1187,30 @@ public open class ReactTextInputManager public constructor() : } } - // Sets the correct password type, since numeric and text passwords have different types - private fun checkPasswordType(view: ReactEditText) { - if ( - (view.stagedInputType and INPUT_TYPE_KEYBOARD_NUMBERED) != 0 && - (view.stagedInputType and InputType.TYPE_TEXT_VARIATION_PASSWORD) != 0 - ) { - // Text input type is numbered password, remove text password variation, add numeric one - updateStagedInputTypeFlag( - view, - InputType.TYPE_TEXT_VARIATION_PASSWORD, - InputType.TYPE_NUMBER_VARIATION_PASSWORD, - ) - } + // A variation is a value within its class, not a set of flags: 0x10 is the URI variation of + // a text field and the password variation of a number field. + private fun reconcileKeyboardInputType(view: ReactEditText) { + val keyboardInputType = + if (view.stagedSecureTextEntry) { + toPasswordInputType(view.stagedKeyboardInputType) + } else { + view.stagedKeyboardInputType + } + view.stagedInputType = + (view.stagedInputType and KEYBOARD_INPUT_TYPE_MASK.inv()) or keyboardInputType } + // Only the text and number classes have a password variation. + private fun toPasswordInputType(keyboardInputType: Int): Int = + when (keyboardInputType and InputType.TYPE_MASK_CLASS) { + InputType.TYPE_CLASS_TEXT -> + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + InputType.TYPE_CLASS_NUMBER -> + (keyboardInputType and InputType.TYPE_MASK_VARIATION.inv()) or + InputType.TYPE_NUMBER_VARIATION_PASSWORD + else -> keyboardInputType + } + private fun updateStagedInputTypeFlag(view: ReactEditText, flagsToUnset: Int, flagsToSet: Int) { view.stagedInputType = (view.stagedInputType and flagsToUnset.inv()) or flagsToSet } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt index 82ec014eb89e..41a97ff40b87 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt @@ -20,6 +20,7 @@ import android.text.Layout import android.text.SpannableString import android.text.Spanned import android.text.TextUtils +import android.text.method.PasswordTransformationMethod import android.util.DisplayMetrics import android.view.Gravity import android.view.View @@ -69,6 +70,11 @@ class ReactTextInputPropertyTest { InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD and InputType.TYPE_TEXT_VARIATION_PASSWORD.inv()) + private val classAndVariation: Int = InputType.TYPE_MASK_CLASS or InputType.TYPE_MASK_VARIATION + + private val numberKeyboardBits: Int = + classAndVariation or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED + @Before fun setup() { ReactNativeFeatureFlagsForTests.setUp() @@ -406,6 +412,115 @@ class ReactTextInputPropertyTest { assertThat(view.inputType and InputType.TYPE_TEXT_VARIATION_PASSWORD).isZero } + @Test + fun testSecureTextEntryFalseKeepsTheKeyboardVariationInEitherPropOrder() { + val textKeyboards = + mapOf( + "email-address" to InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, + "url" to InputType.TYPE_TEXT_VARIATION_URI, + "visible-password" to InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + ) + for ((keyboardType, variation) in textKeyboards) { + for (secureTextEntryFirst in listOf(false, true)) { + val editText = applyKeyboardProps(keyboardType, false, secureTextEntryFirst) + assertThat(editText.inputType and classAndVariation) + .describedAs( + "%s, secureTextEntry applied first: %s", + keyboardType, + secureTextEntryFirst, + ) + .isEqualTo(InputType.TYPE_CLASS_TEXT or variation) + } + } + } + + @Test + fun testSecureTextEntryMasksEveryTextKeyboardInEitherPropOrder() { + for (keyboardType in listOf("default", "email-address", "url", "visible-password")) { + for (secureTextEntryFirst in listOf(false, true)) { + val editText = applyKeyboardProps(keyboardType, true, secureTextEntryFirst) + val subject = "$keyboardType, secureTextEntry applied first: $secureTextEntryFirst" + assertThat(editText.inputType and classAndVariation) + .describedAs(subject) + .isEqualTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) + assertThat(editText.transformationMethod) + .describedAs(subject) + .isInstanceOf(PasswordTransformationMethod::class.java) + } + } + } + + @Test + fun testSecureTextEntryUsesTheNumberPasswordForNumberKeyboards() { + val numberKeyboards = + mapOf( + "number-pad" to InputType.TYPE_CLASS_NUMBER, + "decimal-pad" to (InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL), + "numeric" to + (InputType.TYPE_CLASS_NUMBER or + InputType.TYPE_NUMBER_FLAG_DECIMAL or + InputType.TYPE_NUMBER_FLAG_SIGNED), + ) + for ((keyboardType, keyboardInputType) in numberKeyboards) { + for (secureTextEntryFirst in listOf(false, true)) { + val editText = applyKeyboardProps(keyboardType, true, secureTextEntryFirst) + val subject = "$keyboardType, secureTextEntry applied first: $secureTextEntryFirst" + assertThat(editText.inputType and numberKeyboardBits) + .describedAs(subject) + .isEqualTo(keyboardInputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD) + assertThat(editText.transformationMethod) + .describedAs(subject) + .isInstanceOf(PasswordTransformationMethod::class.java) + } + } + } + + @Test + fun testSecureTextEntryLeavesAKeyboardWithNoPasswordVariationAsItIs() { + for (secureTextEntryFirst in listOf(false, true)) { + val editText = applyKeyboardProps("phone-pad", true, secureTextEntryFirst) + assertThat(editText.inputType and classAndVariation) + .describedAs("secureTextEntry applied first: %s", secureTextEntryFirst) + .isEqualTo(InputType.TYPE_CLASS_PHONE) + } + } + + @Test + fun testRevealingAPasswordRestoresTheKeyboardVariation() { + manager.updateProperties(view, buildStyles("keyboardType", "url", "secureTextEntry", true)) + manager.updateProperties(view, buildStyles("secureTextEntry", false)) + assertThat(view.inputType and classAndVariation) + .isEqualTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) + + manager.updateProperties( + view, + buildStyles("keyboardType", "number-pad", "secureTextEntry", true), + ) + manager.updateProperties(view, buildStyles("secureTextEntry", false)) + assertThat(view.inputType and numberKeyboardBits).isEqualTo(InputType.TYPE_CLASS_NUMBER) + } + + @Test + fun testChangingTheKeyboardTypeReplacesItsVariationAndNumberFlags() { + manager.updateProperties(view, buildStyles("keyboardType", "url")) + manager.updateProperties(view, buildStyles("keyboardType", "email-address")) + assertThat(view.inputType and classAndVariation) + .isEqualTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS) + + manager.updateProperties(view, buildStyles("keyboardType", "numeric")) + manager.updateProperties(view, buildStyles("keyboardType", "number-pad")) + assertThat(view.inputType and numberKeyboardBits).isEqualTo(InputType.TYPE_CLASS_NUMBER) + } + + @Test + fun testSecureTextEntryStaysMaskedWithWordCapitalization() { + manager.updateProperties(view, buildStyles("autoCapitalize", "words")) + manager.updateProperties(view, buildStyles("secureTextEntry", true)) + assertThat(view.inputType and classAndVariation) + .isEqualTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) + assertThat(view.transformationMethod).isInstanceOf(PasswordTransformationMethod::class.java) + } + @Test fun testIncrementalInputTypeUpdates() { manager.updateProperties(view, buildStyles()) @@ -656,5 +771,22 @@ class ReactTextInputPropertyTest { return ReactStylesDiffMap(JavaOnlyMap.of(*keysAndValues)) } + private fun applyKeyboardProps( + keyboardType: String, + secureTextEntry: Boolean, + secureTextEntryFirst: Boolean, + ): ReactEditText { + val editText = manager.createViewInstance(themedContext) + if (secureTextEntryFirst) { + manager.setSecureTextEntry(editText, secureTextEntry) + manager.setKeyboardType(editText, keyboardType) + } else { + manager.setKeyboardType(editText, keyboardType) + manager.setSecureTextEntry(editText, secureTextEntry) + } + manager.updateProperties(editText, buildStyles()) + return editText + } + private class MarkerSpan }