diff --git a/.mapping.json b/.mapping.json index 928f1a061..cace4aeea 100644 --- a/.mapping.json +++ b/.mapping.json @@ -1172,6 +1172,10 @@ "client/android/div/src/main/java/com/yandex/div/core/util/SynchronizedWeakHashMap.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/SynchronizedWeakHashMap.kt", "client/android/div/src/main/java/com/yandex/div/core/util/TypeConverter.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/TypeConverter.kt", "client/android/div/src/main/java/com/yandex/div/core/util/Views.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/Views.kt", + "client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/BaseInputFilter.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/BaseInputFilter.kt", + "client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/ExpressionInputFilter.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/ExpressionInputFilter.kt", + "client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/InputFiltersHolder.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/InputFiltersHolder.kt", + "client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/RegexInputFilter.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/RegexInputFilter.kt", "client/android/div/src/main/java/com/yandex/div/core/util/mask/BaseInputMask.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/mask/BaseInputMask.kt", "client/android/div/src/main/java/com/yandex/div/core/util/mask/CurrencyInputMask.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/mask/CurrencyInputMask.kt", "client/android/div/src/main/java/com/yandex/div/core/util/mask/FixedLengthInputMask.kt":"divkit/public/client/android/div/src/main/java/com/yandex/div/core/util/mask/FixedLengthInputMask.kt", diff --git a/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/BaseInputFilter.kt b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/BaseInputFilter.kt new file mode 100644 index 000000000..9a3e987fd --- /dev/null +++ b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/BaseInputFilter.kt @@ -0,0 +1,5 @@ +package com.yandex.div.core.util.inputfilter + +internal interface BaseInputFilter { + fun checkValue(value: String): Boolean +} diff --git a/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/ExpressionInputFilter.kt b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/ExpressionInputFilter.kt new file mode 100644 index 000000000..e29c7157e --- /dev/null +++ b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/ExpressionInputFilter.kt @@ -0,0 +1,12 @@ +package com.yandex.div.core.util.inputfilter + +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionResolver + +internal class ExpressionInputFilter( + private val condition: Expression, + private val resolver: ExpressionResolver, +) : BaseInputFilter { + + override fun checkValue(value: String) = condition.evaluate(resolver) +} diff --git a/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/InputFiltersHolder.kt b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/InputFiltersHolder.kt new file mode 100644 index 000000000..8c12fc04e --- /dev/null +++ b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/InputFiltersHolder.kt @@ -0,0 +1,9 @@ +package com.yandex.div.core.util.inputfilter + +internal class InputFiltersHolder(private val filters: List) : BaseInputFilter { + + var currentValue = "" + var cursorPosition = 0 + + override fun checkValue(value: String) = filters.all { it.checkValue(value) } +} diff --git a/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/RegexInputFilter.kt b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/RegexInputFilter.kt new file mode 100644 index 000000000..be05d667b --- /dev/null +++ b/client/android/div/src/main/java/com/yandex/div/core/util/inputfilter/RegexInputFilter.kt @@ -0,0 +1,8 @@ +package com.yandex.div.core.util.inputfilter + +internal class RegexInputFilter(pattern: String) : BaseInputFilter { + + private val regex = Regex(pattern) + + override fun checkValue(value: String) = regex.matches(value) +} diff --git a/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivInputBinder.kt b/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivInputBinder.kt index 869ecfcc2..de569696e 100644 --- a/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivInputBinder.kt +++ b/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivInputBinder.kt @@ -1,6 +1,7 @@ package com.yandex.div.core.view2.divs import android.graphics.Color +import android.text.Editable import android.text.InputFilter import android.text.InputType import android.text.method.DigitsKeyListener @@ -16,6 +17,9 @@ import com.yandex.div.core.state.DivStatePath import com.yandex.div.core.util.AccessibilityStateProvider import com.yandex.div.core.util.equalsToConstant import com.yandex.div.core.util.expressionSubscriber +import com.yandex.div.core.util.inputfilter.ExpressionInputFilter +import com.yandex.div.core.util.inputfilter.InputFiltersHolder +import com.yandex.div.core.util.inputfilter.RegexInputFilter import com.yandex.div.core.util.isConstant import com.yandex.div.core.util.mask.BaseInputMask import com.yandex.div.core.util.mask.CurrencyInputMask @@ -43,6 +47,7 @@ import com.yandex.div2.DivAlignmentVertical import com.yandex.div2.DivCurrencyInputMask import com.yandex.div2.DivFixedLengthInputMask import com.yandex.div2.DivInput +import com.yandex.div2.DivInputFilter import com.yandex.div2.DivInputValidator import com.yandex.div2.DivPhoneInputMask import java.util.Locale @@ -335,16 +340,23 @@ internal class DivInputBinder @Inject constructor( removeAfterTextChangeListener() var inputMask: BaseInputMask? = null - observeMask(div, bindingContext.expressionResolver, divView) { inputMask = it - inputMask?.let { mask -> setText(mask.value) setSelection(mask.cursorPosition) } } + var inputFilters: InputFiltersHolder? = null + observeFilters(div, bindingContext) { filters -> + inputFilters = filters + inputFilters?.let { + it.currentValue = editableText?.toString() ?: "" + it.cursorPosition = selectionStart + } + } + val primaryVariable: String? var secondaryVariable: String? = null @@ -357,50 +369,76 @@ internal class DivInputBinder @Inject constructor( primaryVariable = div.textVariable } - val setSecondVariable = { value: String -> - if (secondaryVariable != null) divView.setVariable(secondaryVariable, value) - } - - val callbacks = object : TwoWayStringVariableBinder.Callbacks { - override fun onVariableChanged(value: String?) { - val valueToSet = inputMask?.let { - it.overrideRawValue(value ?: "") - - setSecondVariable(it.value) - - it.value - } ?: value - - setText(valueToSet) - } - - override fun setViewStateChangeListener(valueUpdater: (String) -> Unit) { - addAfterTextChangeAction { editable -> - val fieldValue = editable?.toString() ?: "" - - inputMask?.apply { - if (value != fieldValue) { - applyChangeFrom(text?.toString() ?: "", selectionStart) - - setText(value) - setSelection(cursorPosition) - - setSecondVariable(value) - } - } - - val valueToUpdate = inputMask?.rawValue?.replace(',', '.') ?: fieldValue - - valueUpdater(valueToUpdate) - } - } - } - + val callbacks = createCallbacks(inputMask, inputFilters, divView, secondaryVariable) addSubscription(variableBinder.bindVariable(bindingContext, primaryVariable, callbacks, path)) observeValidators(div, bindingContext.expressionResolver, divView) } + private fun DivInputView.createCallbacks( + inputMask: BaseInputMask?, + filters: InputFiltersHolder?, + divView: Div2View, + secondaryVariable: String?, + ) = object : TwoWayStringVariableBinder.Callbacks { + + override fun onVariableChanged(value: String?) { + val newValue = value ?: "" + + inputMask?.let { + it.overrideRawValue(newValue) + setSecondVariable(it.value) + setText(it.value) + return + } + + filters?.run { + if (!checkValue(newValue)) return + + currentValue = newValue + cursorPosition = newValue.length + } + + setText(newValue) + } + + override fun setViewStateChangeListener(valueUpdater: (String) -> Unit) = + addAfterTextChangeAction { applyMaskOrFilters(it, valueUpdater) } + + private fun applyMaskOrFilters(editable: Editable?, valueUpdater: (String) -> Unit) { + val fieldValue = editable?.toString() ?: "" + + inputMask?.run { + if (value != fieldValue) { + applyChangeFrom(text?.toString() ?: "", selectionStart) + setText(value) + setSelection(cursorPosition) + setSecondVariable(value) + } + valueUpdater(rawValue.replace(',', '.')) + return + } + + filters?.run { + if (currentValue == fieldValue) return + + if (!checkValue(fieldValue)) { + setText(currentValue) + setSelection(cursorPosition) + return + } + + currentValue = fieldValue + cursorPosition = selectionStart + } + + valueUpdater(fieldValue) + } + + private fun setSecondVariable(value: String) = + secondaryVariable?.let { divView.setVariable(secondaryVariable, value) } + } + private fun DivInputView.observeValidators( div: DivInput, resolver: ExpressionResolver, @@ -650,6 +688,47 @@ internal class DivInputBinder @Inject constructor( updateMaskData(Unit) } + private fun DivInputView.observeFilters( + div: DivInput, + bindingContext: BindingContext, + onFiltersUpdate: (InputFiltersHolder?) -> Unit + ) { + div.mask?.let { return } + + val divFilters = div.filters + if (divFilters.isNullOrEmpty()) return + + val resolver = bindingContext.expressionResolver + val updateFiltersData = { _: Any -> + val filters = divFilters.mapNotNull { + when (it) { + is DivInputFilter.Regex -> { + try { + RegexInputFilter(it.value.pattern.evaluate(resolver)) + } catch (e: PatternSyntaxException) { + errorCollectors.getOrCreate(bindingContext.divView.dataTag, bindingContext.divView.divData) + .logError(IllegalArgumentException("Invalid regex pattern '${e.pattern}'.", e)) + null + } + } + is DivInputFilter.Expression -> ExpressionInputFilter(it.value.condition, resolver) + } + } + + onFiltersUpdate(InputFiltersHolder(filters)) + } + + divFilters.forEach { + when (it) { + is DivInputFilter.Regex -> + addSubscription(it.value.pattern.observe(resolver, updateFiltersData)) + is DivInputFilter.Expression -> Unit + } + } + + updateFiltersData(Unit) + } + private fun getCapitalization( div: DivInput, resolver: ExpressionResolver, diff --git a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivInputKeyboardTypeTest.kt b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivInputKeyboardTypeTest.kt index 3338a9796..b80fa7ca3 100644 --- a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivInputKeyboardTypeTest.kt +++ b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivInputKeyboardTypeTest.kt @@ -7,10 +7,8 @@ import com.yandex.divkit.demo.DummyActivity import org.junit.Rule import org.junit.Test -internal const val TEXT_WITH_DIFFERENT_SYMBOLS = - "https://Text_with different+symbols(123)@site.ru\nsecond_line" - private const val TEXT_BEFORE_BREAK = "https://Text_with different+symbols(123)@site.ru" +internal const val TEXT_WITH_DIFFERENT_SYMBOLS = "$TEXT_BEFORE_BREAK\nsecond_line" class DivInputKeyboardTypeTest { @@ -35,6 +33,14 @@ class DivInputKeyboardTypeTest { ) } + @Test + fun checkNumber() { + checkType( + type = "number", + expectedText = "-912302." + ) + } + @Test fun checkPositiveNumber() { checkType( diff --git a/schema/div-input-filter-expression.json b/schema/div-input-filter-expression.json index c7172fa40..318fc635f 100644 --- a/schema/div-input-filter-expression.json +++ b/schema/div-input-filter-expression.json @@ -14,6 +14,7 @@ } }, "platforms": [ + "android", "ios", "web" ], diff --git a/schema/div-input-filter-regex.json b/schema/div-input-filter-regex.json index fa54e4f06..a8f62b151 100644 --- a/schema/div-input-filter-regex.json +++ b/schema/div-input-filter-regex.json @@ -14,6 +14,7 @@ } }, "platforms": [ + "android", "ios", "web" ], diff --git a/schema/div-input.json b/schema/div-input.json index 4520d6a52..db34d1c14 100644 --- a/schema/div-input.json +++ b/schema/div-input.json @@ -63,6 +63,7 @@ "type": "array", "$description": "translations.json#/div_input_filters", "platforms": [ + "android", "ios", "web" ], diff --git a/test_data/regression_test_data/expression_input_filter.json b/test_data/regression_test_data/expression_input_filter.json index 9cad227df..ba67e3001 100644 --- a/test_data/regression_test_data/expression_input_filter.json +++ b/test_data/regression_test_data/expression_input_filter.json @@ -190,6 +190,10 @@ "condition": "@{input_enabled}" } ] + }, + { + "type": "pretty_text", + "text": "Variable value: @{text}" } ] } diff --git a/test_data/regression_test_data/index.json b/test_data/regression_test_data/index.json index c4a50c08b..482cb473b 100644 --- a/test_data/regression_test_data/index.json +++ b/test_data/regression_test_data/index.json @@ -2435,6 +2435,7 @@ "title": "Regex input filter", "case_id": 158, "platforms": [ + "android", "ios", "web" ], @@ -2463,6 +2464,7 @@ "title": "Expression input filter", "case_id": 159, "platforms": [ + "android", "ios", "web" ], diff --git a/test_data/regression_test_data/regex_input_filter.json b/test_data/regression_test_data/regex_input_filter.json index a10e7e351..84c6485eb 100644 --- a/test_data/regression_test_data/regex_input_filter.json +++ b/test_data/regression_test_data/regex_input_filter.json @@ -120,6 +120,10 @@ "type": "pretty_input", "variable": "regex", "hint": "regex" + }, + { + "type": "pretty_text", + "text": "Variable value: @{input}" } ] }