From f9a2208832d89af724fe590a884f562d751514c5 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 1 Dec 2021 15:53:55 -0400 Subject: [PATCH] Fix non-standard numeral entry. --- .../settings/app/subscription/boost/Boost.kt | 26 +++++++++++-- .../app/subscription/boost/BoostViewModel.kt | 4 +- .../securesms/util/StringUtil.java | 4 ++ .../boost/BoostTest__MoneyFilter.kt | 37 +++++++++++++++++++ .../org/signal/core/util/money/FiatMoney.java | 2 +- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt index 29e236282..c6cbe17e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.MappingAdapter import org.thoughtcrime.securesms.util.MappingViewHolder +import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.ViewUtil import java.lang.Integer.min import java.text.DecimalFormatSymbols @@ -197,7 +198,19 @@ data class Boost( val separator = DecimalFormatSymbols.getInstance().decimalSeparator val separatorCount = min(1, currency.defaultFractionDigits) val symbol: String = currency.getSymbol(Locale.getDefault()) - val pattern: Pattern = "[0-9]*([$separator]){0,$separatorCount}[0-9]{0,${currency.defaultFractionDigits}}".toPattern() + + /** + * From Character.isDigit: + * + * * '\u0030' through '\u0039', ISO-LATIN-1 digits ('0' through '9') + * * '\u0660' through '\u0669', Arabic-Indic digits + * * '\u06F0' through '\u06F9', Extended Arabic-Indic digits + * * '\u0966' through '\u096F', Devanagari digits + * * '\uFF10' through '\uFF19', Fullwidth digits + */ + val digitsGroup: String = "[\\u0030-\\u0039]|[\\u0660-\\u0669]|[\\u06F0-\\u06F9]|[\\u0966-\\u096F]|[\\uFF10-\\uFF19]" + + val pattern: Pattern = "($digitsGroup)*([$separator]){0,$separatorCount}($digitsGroup){0,${currency.defaultFractionDigits}}".toPattern() val symbolPattern: Regex = """\s*${Regex.escape(symbol)}\s*""".toRegex() val leadingZeroesPattern: Regex = """^0*""".toRegex() @@ -211,7 +224,7 @@ data class Boost( ): CharSequence? { val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length) - val resultWithoutCurrencyPrefix = result.removePrefix(symbol).removeSuffix(symbol).trim() + val resultWithoutCurrencyPrefix = StringUtil.stripBidiIndicator(result.removePrefix(symbol).removeSuffix(symbol).trim()) if (resultWithoutCurrencyPrefix.length == 1 && !resultWithoutCurrencyPrefix.isDigitsOnly() && resultWithoutCurrencyPrefix != separator.toString()) { return dest.subSequence(dstart, dend) @@ -262,7 +275,14 @@ data class Boost( } val withoutSymbol = s.removePrefix(symbol).removeSuffix(symbol).trim().toString() - val withoutLeadingZeroes = withoutSymbol.replace(leadingZeroesPattern, "") + val withoutLeadingZeroes: String = try { + NumberFormat.getInstance().apply { + isGroupingUsed = false + }.format(withoutSymbol.toBigDecimal()) + (if (withoutSymbol.endsWith(separator)) separator else "") + } catch (e: NumberFormatException) { + withoutSymbol + }.replace(leadingZeroesPattern, "") + if (withoutSymbol != withoutLeadingZeroes) { text?.removeTextChangedListener(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt index 5a071f0e6..a99b442cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.InternetConnectionObserver import org.thoughtcrime.securesms.util.PlatformCurrencyUtil +import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.livedata.Store import java.lang.NumberFormatException import java.math.BigDecimal @@ -196,7 +197,8 @@ class BoostViewModel( } } - fun setCustomAmount(amount: String) { + fun setCustomAmount(rawAmount: String) { + val amount = StringUtil.stripBidiIndicator(rawAmount) val bigDecimalAmount: BigDecimal = if (amount.isEmpty() || amount == DecimalFormatSymbols.getInstance().decimalSeparator.toString()) { BigDecimal.ZERO } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java index 4ff440736..21337c234 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java @@ -233,6 +233,10 @@ public final class StringUtil { return text.replaceAll("[\\u2068\\u2069\\u202c]", ""); } + public static @NonNull String stripBidiIndicator(@NonNull String text) { + return text.replace("\u200F", ""); + } + /** * Trims a {@link CharSequence} of starting and trailing whitespace. Behavior matches * {@link String#trim()} to preserve expectations around results. diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt index a5e7af69e..cb155790e 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt @@ -20,6 +20,7 @@ class BoostTest__MoneyFilter { private val usd = Currency.getInstance("USD") private val yen = Currency.getInstance("JPY") + private val inr = Currency.getInstance("INR") @Before fun setUp() { @@ -142,4 +143,40 @@ class BoostTest__MoneyFilter { assertNotNull(filterResult) } + + @Test + fun `Given MR and INR, when I enter 5dot55, then I expect localized`() { + Locale.setDefault(Locale.forLanguageTag("mr")) + + val testSubject = Boost.MoneyFilter(inr) + val editable = SpannableStringBuilder("5.55") + + testSubject.afterTextChanged(editable) + + assertEquals("₹५.५५", editable.toString()) + } + + @Test + fun `Given MR and INR, when I enter dot, then I expect it to be retained in output`() { + Locale.setDefault(Locale.forLanguageTag("mr")) + + val testSubject = Boost.MoneyFilter(inr) + val editable = SpannableStringBuilder("₹५.") + + testSubject.afterTextChanged(editable) + + assertEquals("₹५.", editable.toString()) + } + + @Test + fun `Given RTL indicator, when I enter five, then I expect successful match`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("\u200F5") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } } diff --git a/core-util/src/main/java/org/signal/core/util/money/FiatMoney.java b/core-util/src/main/java/org/signal/core/util/money/FiatMoney.java index 9152ca52e..b0d4519b0 100644 --- a/core-util/src/main/java/org/signal/core/util/money/FiatMoney.java +++ b/core-util/src/main/java/org/signal/core/util/money/FiatMoney.java @@ -59,7 +59,7 @@ public class FiatMoney { * @return amount, in smallest possible units (cents, yen, etc.) */ public @NonNull String getMinimumUnitPrecisionString() { - NumberFormat formatter = NumberFormat.getInstance(); + NumberFormat formatter = NumberFormat.getInstance(Locale.US); formatter.setMaximumFractionDigits(0); formatter.setGroupingUsed(false);