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 754f1002662..e58ebcaa994 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 82e5abd361b..2e839b234ad 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 82ec014eb89..41a97ff40b8 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 }